Add channel joining UI, closes #37
This commit is contained in:
parent
f25594e962
commit
24b26aa85f
File diff suppressed because one or more lines are too long
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
@ -23,6 +24,7 @@ type ircHandler struct {
|
||||
whois WhoisReply
|
||||
userBuffers map[string][]string
|
||||
motdBuffer MOTD
|
||||
listBuffer storage.ChannelListIndex
|
||||
|
||||
handlers map[string]func(*irc.Message)
|
||||
}
|
||||
@ -183,6 +185,12 @@ func (i *ircHandler) info(msg *irc.Message) {
|
||||
New: msg.Params[0],
|
||||
})
|
||||
|
||||
_, needsUpdate := channelIndexes.Get(i.client.Host)
|
||||
if needsUpdate {
|
||||
i.listBuffer = storage.NewMapChannelListIndex()
|
||||
i.client.List()
|
||||
}
|
||||
|
||||
go i.state.user.SetNick(msg.Params[0], i.client.Host)
|
||||
}
|
||||
|
||||
@ -281,6 +289,34 @@ func (i *ircHandler) motdEnd(msg *irc.Message) {
|
||||
i.motdBuffer = MOTD{}
|
||||
}
|
||||
|
||||
func (i *ircHandler) list(msg *irc.Message) {
|
||||
if i.listBuffer == nil && i.state.Bool("update_chanlist_"+i.client.Host) {
|
||||
i.listBuffer = storage.NewMapChannelListIndex()
|
||||
}
|
||||
|
||||
if i.listBuffer != nil {
|
||||
c, _ := strconv.Atoi(msg.Params[2])
|
||||
i.listBuffer.Add(&storage.ChannelListItem{
|
||||
Name: msg.Params[1],
|
||||
UserCount: c,
|
||||
Topic: msg.LastParam(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ircHandler) listEnd(msg *irc.Message) {
|
||||
if i.listBuffer != nil {
|
||||
i.state.Set("update_chanlist_"+i.client.Host, false)
|
||||
|
||||
go func(idx storage.ChannelListIndex) {
|
||||
idx.Finish()
|
||||
channelIndexes.Set(i.client.Host, idx)
|
||||
}(i.listBuffer)
|
||||
|
||||
i.listBuffer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ircHandler) badNick(msg *irc.Message) {
|
||||
i.state.sendJSON("nick_fail", NickFail{
|
||||
Server: i.client.Host,
|
||||
@ -321,6 +357,8 @@ func (i *ircHandler) initHandlers() {
|
||||
irc.ReplyMotdStart: i.motdStart,
|
||||
irc.ReplyMotd: i.motd,
|
||||
irc.ReplyEndOfMotd: i.motdEnd,
|
||||
irc.ReplyList: i.list,
|
||||
irc.ReplyListEnd: i.listEnd,
|
||||
irc.ErrErroneousNickname: i.badNick,
|
||||
}
|
||||
}
|
||||
|
@ -192,3 +192,14 @@ type Error struct {
|
||||
Server string
|
||||
Message string
|
||||
}
|
||||
|
||||
type ChannelSearch struct {
|
||||
Server string
|
||||
Q string
|
||||
Start int
|
||||
}
|
||||
|
||||
type ChannelSearchResult struct {
|
||||
Results []*storage.ChannelListItem
|
||||
Start int
|
||||
}
|
||||
|
@ -3001,7 +3001,298 @@ func (v *ClientCert) UnmarshalJSON(data []byte) error {
|
||||
func (v *ClientCert) UnmarshalEasyJSON(l *jlexer.Lexer) {
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer26(l, v)
|
||||
}
|
||||
func easyjson42239ddeDecodeGithubComKhliengDispatchServer27(in *jlexer.Lexer, out *Away) {
|
||||
func easyjson42239ddeDecodeGithubComKhliengDispatchServer27(in *jlexer.Lexer, out *ChannelSearchResult) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeString()
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "results":
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
out.Results = nil
|
||||
} else {
|
||||
in.Delim('[')
|
||||
if out.Results == nil {
|
||||
if !in.IsDelim(']') {
|
||||
out.Results = make([]*storage.ChannelListItem, 0, 8)
|
||||
} else {
|
||||
out.Results = []*storage.ChannelListItem{}
|
||||
}
|
||||
} else {
|
||||
out.Results = (out.Results)[:0]
|
||||
}
|
||||
for !in.IsDelim(']') {
|
||||
var v22 *storage.ChannelListItem
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
v22 = nil
|
||||
} else {
|
||||
if v22 == nil {
|
||||
v22 = new(storage.ChannelListItem)
|
||||
}
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in, &*v22)
|
||||
}
|
||||
out.Results = append(out.Results, v22)
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim(']')
|
||||
}
|
||||
case "start":
|
||||
out.Start = int(in.Int())
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjson42239ddeEncodeGithubComKhliengDispatchServer27(out *jwriter.Writer, in ChannelSearchResult) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
if len(in.Results) != 0 {
|
||||
const prefix string = ",\"results\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
{
|
||||
out.RawByte('[')
|
||||
for v23, v24 := range in.Results {
|
||||
if v23 > 0 {
|
||||
out.RawByte(',')
|
||||
}
|
||||
if v24 == nil {
|
||||
out.RawString("null")
|
||||
} else {
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchStorage1(out, *v24)
|
||||
}
|
||||
}
|
||||
out.RawByte(']')
|
||||
}
|
||||
}
|
||||
if in.Start != 0 {
|
||||
const prefix string = ",\"start\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.Int(int(in.Start))
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
|
||||
// MarshalJSON supports json.Marshaler interface
|
||||
func (v ChannelSearchResult) MarshalJSON() ([]byte, error) {
|
||||
w := jwriter.Writer{}
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer27(&w, v)
|
||||
return w.Buffer.BuildBytes(), w.Error
|
||||
}
|
||||
|
||||
// MarshalEasyJSON supports easyjson.Marshaler interface
|
||||
func (v ChannelSearchResult) MarshalEasyJSON(w *jwriter.Writer) {
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer27(w, v)
|
||||
}
|
||||
|
||||
// UnmarshalJSON supports json.Unmarshaler interface
|
||||
func (v *ChannelSearchResult) UnmarshalJSON(data []byte) error {
|
||||
r := jlexer.Lexer{Data: data}
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer27(&r, v)
|
||||
return r.Error()
|
||||
}
|
||||
|
||||
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
|
||||
func (v *ChannelSearchResult) UnmarshalEasyJSON(l *jlexer.Lexer) {
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer27(l, v)
|
||||
}
|
||||
func easyjson42239ddeDecodeGithubComKhliengDispatchStorage1(in *jlexer.Lexer, out *storage.ChannelListItem) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeString()
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "name":
|
||||
out.Name = string(in.String())
|
||||
case "userCount":
|
||||
out.UserCount = int(in.Int())
|
||||
case "topic":
|
||||
out.Topic = string(in.String())
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjson42239ddeEncodeGithubComKhliengDispatchStorage1(out *jwriter.Writer, in storage.ChannelListItem) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
if in.Name != "" {
|
||||
const prefix string = ",\"name\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.String(string(in.Name))
|
||||
}
|
||||
if in.UserCount != 0 {
|
||||
const prefix string = ",\"userCount\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.Int(int(in.UserCount))
|
||||
}
|
||||
if in.Topic != "" {
|
||||
const prefix string = ",\"topic\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.String(string(in.Topic))
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
func easyjson42239ddeDecodeGithubComKhliengDispatchServer28(in *jlexer.Lexer, out *ChannelSearch) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
in.Skip()
|
||||
return
|
||||
}
|
||||
in.Delim('{')
|
||||
for !in.IsDelim('}') {
|
||||
key := in.UnsafeString()
|
||||
in.WantColon()
|
||||
if in.IsNull() {
|
||||
in.Skip()
|
||||
in.WantComma()
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "server":
|
||||
out.Server = string(in.String())
|
||||
case "q":
|
||||
out.Q = string(in.String())
|
||||
case "start":
|
||||
out.Start = int(in.Int())
|
||||
default:
|
||||
in.SkipRecursive()
|
||||
}
|
||||
in.WantComma()
|
||||
}
|
||||
in.Delim('}')
|
||||
if isTopLevel {
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjson42239ddeEncodeGithubComKhliengDispatchServer28(out *jwriter.Writer, in ChannelSearch) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
if in.Server != "" {
|
||||
const prefix string = ",\"server\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.String(string(in.Server))
|
||||
}
|
||||
if in.Q != "" {
|
||||
const prefix string = ",\"q\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.String(string(in.Q))
|
||||
}
|
||||
if in.Start != 0 {
|
||||
const prefix string = ",\"start\":"
|
||||
if first {
|
||||
first = false
|
||||
out.RawString(prefix[1:])
|
||||
} else {
|
||||
out.RawString(prefix)
|
||||
}
|
||||
out.Int(int(in.Start))
|
||||
}
|
||||
out.RawByte('}')
|
||||
}
|
||||
|
||||
// MarshalJSON supports json.Marshaler interface
|
||||
func (v ChannelSearch) MarshalJSON() ([]byte, error) {
|
||||
w := jwriter.Writer{}
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer28(&w, v)
|
||||
return w.Buffer.BuildBytes(), w.Error
|
||||
}
|
||||
|
||||
// MarshalEasyJSON supports easyjson.Marshaler interface
|
||||
func (v ChannelSearch) MarshalEasyJSON(w *jwriter.Writer) {
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer28(w, v)
|
||||
}
|
||||
|
||||
// UnmarshalJSON supports json.Unmarshaler interface
|
||||
func (v *ChannelSearch) UnmarshalJSON(data []byte) error {
|
||||
r := jlexer.Lexer{Data: data}
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer28(&r, v)
|
||||
return r.Error()
|
||||
}
|
||||
|
||||
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
|
||||
func (v *ChannelSearch) UnmarshalEasyJSON(l *jlexer.Lexer) {
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer28(l, v)
|
||||
}
|
||||
func easyjson42239ddeDecodeGithubComKhliengDispatchServer29(in *jlexer.Lexer, out *Away) {
|
||||
isTopLevel := in.IsStart()
|
||||
if in.IsNull() {
|
||||
if isTopLevel {
|
||||
@ -3034,7 +3325,7 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer27(in *jlexer.Lexer, ou
|
||||
in.Consumed()
|
||||
}
|
||||
}
|
||||
func easyjson42239ddeEncodeGithubComKhliengDispatchServer27(out *jwriter.Writer, in Away) {
|
||||
func easyjson42239ddeEncodeGithubComKhliengDispatchServer29(out *jwriter.Writer, in Away) {
|
||||
out.RawByte('{')
|
||||
first := true
|
||||
_ = first
|
||||
@ -3064,23 +3355,23 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer27(out *jwriter.Writer,
|
||||
// MarshalJSON supports json.Marshaler interface
|
||||
func (v Away) MarshalJSON() ([]byte, error) {
|
||||
w := jwriter.Writer{}
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer27(&w, v)
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer29(&w, v)
|
||||
return w.Buffer.BuildBytes(), w.Error
|
||||
}
|
||||
|
||||
// MarshalEasyJSON supports easyjson.Marshaler interface
|
||||
func (v Away) MarshalEasyJSON(w *jwriter.Writer) {
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer27(w, v)
|
||||
easyjson42239ddeEncodeGithubComKhliengDispatchServer29(w, v)
|
||||
}
|
||||
|
||||
// UnmarshalJSON supports json.Unmarshaler interface
|
||||
func (v *Away) UnmarshalJSON(data []byte) error {
|
||||
r := jlexer.Lexer{Data: data}
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer27(&r, v)
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer29(&r, v)
|
||||
return r.Error()
|
||||
}
|
||||
|
||||
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface
|
||||
func (v *Away) UnmarshalEasyJSON(l *jlexer.Lexer) {
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer27(l, v)
|
||||
easyjson42239ddeDecodeGithubComKhliengDispatchServer29(l, v)
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
)
|
||||
|
||||
var channelStore = storage.NewChannelStore()
|
||||
var channelIndexes = storage.NewChannelIndexManager()
|
||||
|
||||
type Dispatch struct {
|
||||
Store storage.Store
|
||||
|
@ -13,10 +13,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// AnonymousUserExpiration is the time to wait before removing an anonymous
|
||||
// user that has no irc or websocket connections
|
||||
AnonymousUserExpiration = 1 * time.Minute
|
||||
)
|
||||
|
||||
// State is the live state of a single user
|
||||
type State struct {
|
||||
stateData
|
||||
|
||||
irc map[string]*irc.Client
|
||||
connectionState map[string]irc.ConnectionState
|
||||
ircLock sync.Mutex
|
||||
@ -33,6 +38,7 @@ type State struct {
|
||||
|
||||
func NewState(user *storage.User, srv *Dispatch) *State {
|
||||
return &State{
|
||||
stateData: stateData{m: map[string]interface{}{}},
|
||||
irc: make(map[string]*irc.Client),
|
||||
connectionState: make(map[string]irc.ConnectionState),
|
||||
ws: make(map[string]*wsConn),
|
||||
@ -225,6 +231,36 @@ func (s *State) run() {
|
||||
}
|
||||
}
|
||||
|
||||
type stateData struct {
|
||||
m map[string]interface{}
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func (s stateData) Get(key string) interface{} {
|
||||
s.lock.Lock()
|
||||
v := s.m[key]
|
||||
s.lock.Unlock()
|
||||
return v
|
||||
}
|
||||
|
||||
func (s stateData) Set(key string, value interface{}) {
|
||||
s.lock.Lock()
|
||||
s.m[key] = value
|
||||
s.lock.Unlock()
|
||||
}
|
||||
|
||||
func (s stateData) String(key string) string {
|
||||
return s.Get(key).(string)
|
||||
}
|
||||
|
||||
func (s stateData) Int(key string) int {
|
||||
return s.Get(key).(int)
|
||||
}
|
||||
|
||||
func (s stateData) Bool(key string) bool {
|
||||
return s.Get(key).(bool)
|
||||
}
|
||||
|
||||
type stateStore struct {
|
||||
states map[uint64]*State
|
||||
sessions map[string]*session.Session
|
||||
|
@ -97,6 +97,8 @@ func (h *wsHandler) connect(b []byte) {
|
||||
var data Server
|
||||
data.UnmarshalJSON(b)
|
||||
|
||||
data.Host = strings.ToLower(data.Host)
|
||||
|
||||
if _, ok := h.state.getIRC(data.Host); !ok {
|
||||
log.Println(h.addr, "[IRC] Add server", data.Host)
|
||||
|
||||
@ -281,6 +283,29 @@ func (h *wsHandler) setSettings(b []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHandler) channelSearch(b []byte) {
|
||||
var data ChannelSearch
|
||||
data.UnmarshalJSON(b)
|
||||
|
||||
index, needsUpdate := channelIndexes.Get(data.Server)
|
||||
if index != nil {
|
||||
n := 10
|
||||
if data.Start > 0 {
|
||||
n = 50
|
||||
}
|
||||
|
||||
h.state.sendJSON("channel_search", ChannelSearchResult{
|
||||
Results: index.SearchN(data.Q, data.Start, n),
|
||||
Start: data.Start,
|
||||
})
|
||||
}
|
||||
|
||||
if i, ok := h.state.getIRC(data.Server); ok && needsUpdate {
|
||||
h.state.Set("update_chanlist_"+data.Server, true)
|
||||
i.List()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *wsHandler) initHandlers() {
|
||||
h.handlers = map[string]func([]byte){
|
||||
"connect": h.connect,
|
||||
@ -301,6 +326,7 @@ func (h *wsHandler) initHandlers() {
|
||||
"fetch_messages": h.fetchMessages,
|
||||
"set_server_name": h.setServerName,
|
||||
"settings_set": h.setSettings,
|
||||
"channel_search": h.channelSearch,
|
||||
}
|
||||
}
|
||||
|
||||
|
148
storage/channel_index.go
Normal file
148
storage/channel_index.go
Normal file
@ -0,0 +1,148 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChannelListItem struct {
|
||||
Name string
|
||||
UserCount int
|
||||
Topic string
|
||||
}
|
||||
|
||||
type ChannelListIndex interface {
|
||||
Add(item *ChannelListItem)
|
||||
Finish()
|
||||
Search(q string) []*ChannelListItem
|
||||
SearchN(q string, start, n int) []*ChannelListItem
|
||||
}
|
||||
|
||||
type MapChannelListIndex struct {
|
||||
channels chanList
|
||||
m map[string][]*ChannelListItem
|
||||
}
|
||||
|
||||
func NewMapChannelListIndex() *MapChannelListIndex {
|
||||
return &MapChannelListIndex{
|
||||
m: map[string][]*ChannelListItem{},
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *MapChannelListIndex) Add(item *ChannelListItem) {
|
||||
idx.channels = append(idx.channels, item)
|
||||
}
|
||||
|
||||
func (idx *MapChannelListIndex) Finish() {
|
||||
sort.Sort(idx.channels)
|
||||
|
||||
for _, ch := range idx.channels {
|
||||
key := strings.TrimLeft(strings.ToLower(ch.Name), "#")
|
||||
|
||||
for i := 1; i <= len(key); i++ {
|
||||
k := key[:i]
|
||||
if _, ok := idx.m[k]; ok {
|
||||
idx.m[k] = append(idx.m[k], ch)
|
||||
} else {
|
||||
idx.m[k] = chanList{ch}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (idx *MapChannelListIndex) Search(q string) []*ChannelListItem {
|
||||
if q == "" {
|
||||
return idx.channels
|
||||
}
|
||||
return idx.m[q]
|
||||
}
|
||||
|
||||
func (idx *MapChannelListIndex) SearchN(q string, start, n int) []*ChannelListItem {
|
||||
if q == "" {
|
||||
if start >= len(idx.channels) {
|
||||
return nil
|
||||
}
|
||||
return idx.channels[start:min(start+n, len(idx.channels))]
|
||||
}
|
||||
|
||||
res := idx.m[q]
|
||||
if start >= len(res) {
|
||||
return nil
|
||||
}
|
||||
return res[start:min(start+n, len(res))]
|
||||
}
|
||||
|
||||
func min(x, y int) int {
|
||||
if x < y {
|
||||
return x
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
type chanList []*ChannelListItem
|
||||
|
||||
func (c chanList) Len() int {
|
||||
return len(c)
|
||||
}
|
||||
|
||||
func (c chanList) Less(i, j int) bool {
|
||||
return c[i].UserCount > c[j].UserCount ||
|
||||
(c[i].UserCount == c[j].UserCount &&
|
||||
strings.ToLower(c[i].Name) < strings.ToLower(c[j].Name))
|
||||
}
|
||||
|
||||
func (c chanList) Swap(i, j int) {
|
||||
ch := c[i]
|
||||
c[i] = c[j]
|
||||
c[j] = ch
|
||||
}
|
||||
|
||||
const ChannelListUpdateInterval = time.Hour * 24
|
||||
|
||||
type ChannelIndexManager struct {
|
||||
indexes map[string]*managedChannelIndex
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func NewChannelIndexManager() *ChannelIndexManager {
|
||||
return &ChannelIndexManager{
|
||||
indexes: map[string]*managedChannelIndex{},
|
||||
}
|
||||
}
|
||||
|
||||
type managedChannelIndex struct {
|
||||
index ChannelListIndex
|
||||
updatedAt time.Time
|
||||
updating bool
|
||||
}
|
||||
|
||||
func (m *ChannelIndexManager) Get(server string) (ChannelListIndex, bool) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
idx, ok := m.indexes[server]
|
||||
if !ok {
|
||||
m.indexes[server] = &managedChannelIndex{
|
||||
updating: true,
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
|
||||
if !idx.updating && time.Since(idx.updatedAt) > ChannelListUpdateInterval {
|
||||
idx.updating = true
|
||||
return idx.index, true
|
||||
}
|
||||
|
||||
return idx.index, false
|
||||
}
|
||||
|
||||
func (m *ChannelIndexManager) Set(server string, index ChannelListIndex) {
|
||||
m.lock.Lock()
|
||||
m.indexes[server] = &managedChannelIndex{
|
||||
index: index,
|
||||
updatedAt: time.Now(),
|
||||
}
|
||||
m.lock.Unlock()
|
||||
}
|
44
storage/channel_index_test.go
Normal file
44
storage/channel_index_test.go
Normal file
@ -0,0 +1,44 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMapChannelListIndex(t *testing.T) {
|
||||
i := NewMapChannelListIndex()
|
||||
i.Add(&ChannelListItem{
|
||||
Name: "#apples",
|
||||
UserCount: 120,
|
||||
})
|
||||
i.Add(&ChannelListItem{
|
||||
Name: "#cake",
|
||||
UserCount: 150,
|
||||
})
|
||||
i.Add(&ChannelListItem{
|
||||
Name: "#beans",
|
||||
UserCount: 12,
|
||||
})
|
||||
i.Add(&ChannelListItem{
|
||||
Name: "#pie",
|
||||
UserCount: 1200,
|
||||
})
|
||||
i.Add(&ChannelListItem{
|
||||
Name: "#Pork",
|
||||
UserCount: 1200,
|
||||
})
|
||||
i.Finish()
|
||||
|
||||
assert.Len(t, i.Search(""), 5)
|
||||
assert.Len(t, i.SearchN("", 0, 20), 5)
|
||||
assert.Len(t, i.SearchN("", 0, 1), 1)
|
||||
assert.Len(t, i.SearchN("", 1, 1), 1)
|
||||
assert.Len(t, i.SearchN("", 0, 0), 0)
|
||||
|
||||
assert.Equal(t, "#pie", i.Search("")[0].Name)
|
||||
assert.Equal(t, "#Pork", i.Search("")[1].Name)
|
||||
|
||||
assert.Len(t, i.Search("p"), 2)
|
||||
assert.Equal(t, "#Pork", i.Search("p")[1].Name)
|
||||
}
|
Loading…
Reference in New Issue
Block a user