Add channel joining UI, closes #37

This commit is contained in:
Ken-Håvard Lieng 2019-01-23 07:34:39 +01:00
parent f25594e962
commit 24b26aa85f
20 changed files with 1131 additions and 177 deletions

View file

@ -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>

View file

@ -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>
);

View 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);

View file

@ -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 />
</>
);

View file

@ -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

View file

@ -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>
);

View file

@ -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,

View file

@ -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',

View 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 }
}
};
}

View file

@ -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,