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

@ -237,7 +237,8 @@ i[class*=' icon-']:before {
top: 0;
bottom: 50px;
width: 100%;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
.tablist p {
@ -245,6 +246,9 @@ i[class*=' icon-']:before {
padding: 3px 15px;
padding-right: 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tablist p:last-child {
@ -260,12 +264,6 @@ i[class*=' icon-']:before {
border-left: 5px solid #6bb758;
}
.tab-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-server {
display: flex;
align-items: center;
@ -279,11 +277,29 @@ i[class*=' icon-']:before {
}
.tab-label {
margin-top: 10px;
margin: 5px;
margin-left: 15px;
margin-bottom: 5px;
font-size: 12px;
color: #999;
display: flex;
align-items: center;
height: 25px;
}
.tab-label span {
flex: 1;
}
.tab-label button {
width: 24px;
height: 100%;
font-size: 20px;
background: none;
color: #999;
}
.tab-label button:hover {
color: #ccc;
}
.side-buttons {
@ -859,11 +875,24 @@ input.message-input-nick.invalid {
}
.suspense-fallback {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font: 700 64px 'Montserrat', sans-serif;
height: 100%;
color: #ddd;
}
.suspense-modal-fallback {
position: fixed;
right: 15px;
bottom: 3px;
z-index: 1;
font: 700 64px 'Montserrat', sans-serif;
color: #ddd;
}
@ -874,6 +903,7 @@ input.message-input-nick.invalid {
right: 0;
bottom: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.33);
@ -890,7 +920,8 @@ input.message-input-nick.invalid {
}
.modal {
max-width: 600px;
width: 600px;
min-width: 0;
padding: 15px;
background: #f0f0f0;
border: 1px solid #ddd;
@ -923,6 +954,82 @@ input.message-input-nick.invalid {
margin-top: 10px;
}
.modal-channel {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
padding: 0;
}
.modal-channel input {
flex: 1;
padding: 15px;
}
.modal-channel-button-join {
margin: 0 !important;
width: 50px !important;
height: 24px;
font-size: 12px;
}
.modal-channel-input-wrap {
display: flex;
}
.modal-channel-close {
padding: 15px;
background: #fff;
color: #999;
cursor: pointer;
}
.modal-channel-close:hover {
color: #222;
}
.modal-channel-result {
margin: 15px;
text-align: left;
}
.modal-channel-result-header {
display: flex;
align-items: center;
}
.modal-channel-topic {
font-size: 12px;
font-family: Roboto Mono, monospace;
color: #444;
}
.modal-channel-name {
margin-bottom: 5px;
cursor: pointer;
}
.modal-channel-users {
font-size: 16px;
color: #444;
margin: 0 15px;
flex: 1;
}
.modal-channel-users i {
margin-right: 3px;
}
.modal-channel-results {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.modal-channel-button-more {
margin-bottom: 15px !important;
}
@media (max-width: 600px) {
.tablist {
width: 200px;
@ -992,4 +1099,15 @@ input.message-input-nick.invalid {
.button-install {
margin-left: 50px;
}
.modal-channel {
margin: 0;
width: auto;
height: auto;
position: fixed;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
}
}

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,