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

File diff suppressed because one or more lines are too long

View File

@ -237,7 +237,8 @@ i[class*=' icon-']:before {
top: 0; top: 0;
bottom: 50px; bottom: 50px;
width: 100%; width: 100%;
overflow: auto; overflow-x: hidden;
overflow-y: auto;
} }
.tablist p { .tablist p {
@ -245,6 +246,9 @@ i[class*=' icon-']:before {
padding: 3px 15px; padding: 3px 15px;
padding-right: 10px; padding-right: 10px;
cursor: pointer; cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.tablist p:last-child { .tablist p:last-child {
@ -260,12 +264,6 @@ i[class*=' icon-']:before {
border-left: 5px solid #6bb758; border-left: 5px solid #6bb758;
} }
.tab-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-server { .tab-server {
display: flex; display: flex;
align-items: center; align-items: center;
@ -279,11 +277,29 @@ i[class*=' icon-']:before {
} }
.tab-label { .tab-label {
margin-top: 10px; margin: 5px;
margin-left: 15px; margin-left: 15px;
margin-bottom: 5px;
font-size: 12px; font-size: 12px;
color: #999; 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 { .side-buttons {
@ -859,11 +875,24 @@ input.message-input-nick.invalid {
} }
.suspense-fallback { .suspense-fallback {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font: 700 64px 'Montserrat', sans-serif; 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; color: #ddd;
} }
@ -874,6 +903,7 @@ input.message-input-nick.invalid {
right: 0; right: 0;
bottom: 0; bottom: 0;
display: flex; display: flex;
flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(0, 0, 0, 0.33); background: rgba(0, 0, 0, 0.33);
@ -890,7 +920,8 @@ input.message-input-nick.invalid {
} }
.modal { .modal {
max-width: 600px; width: 600px;
min-width: 0;
padding: 15px; padding: 15px;
background: #f0f0f0; background: #f0f0f0;
border: 1px solid #ddd; border: 1px solid #ddd;
@ -923,6 +954,82 @@ input.message-input-nick.invalid {
margin-top: 10px; 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) { @media (max-width: 600px) {
.tablist { .tablist {
width: 200px; width: 200px;
@ -992,4 +1099,15 @@ input.message-input-nick.invalid {
.button-install { .button-install {
margin-left: 50px; 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, select,
push, push,
hideMenu, hideMenu,
openModal,
newVersionAvailable, newVersionAvailable,
hasOpenModals hasOpenModals
}) => { }) => {
@ -59,10 +60,10 @@ const App = ({
showTabList={showTabList} showTabList={showTabList}
select={select} select={select}
push={push} push={push}
openModal={openModal}
/> />
<div className={mainClass}> <div className={mainClass}>
<Suspense fallback={<div className="suspense-fallback">...</div>}> <Suspense fallback={<div className="suspense-fallback">...</div>}>
{renderModals && <Modals />}
<Route name="chat"> <Route name="chat">
<Chat /> <Chat />
</Route> </Route>
@ -73,6 +74,11 @@ const App = ({
<Settings /> <Settings />
</Route> </Route>
</Suspense> </Suspense>
<Suspense
fallback={<div className="suspense-modal-fallback">...</div>}
>
{renderModals && <Modals />}
</Suspense>
</div> </div>
</div> </div>
</div> </div>

View File

@ -11,7 +11,14 @@ export default class TabList extends PureComponent {
handleSettingsClick = () => this.props.push('/settings'); handleSettingsClick = () => this.props.push('/settings');
render() { render() {
const { tab, channels, servers, privateChats, showTabList } = this.props; const {
tab,
channels,
servers,
privateChats,
showTabList,
openModal
} = this.props;
const tabs = []; const tabs = [];
const className = classnames('tablist', { 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 => server.channels.forEach(name =>
tabs.push( tabs.push(
<TabListItem <TabListItem
@ -48,7 +66,8 @@ export default class TabList extends PureComponent {
if (privateChats[address] && privateChats[address].length > 0) { if (privateChats[address] && privateChats[address].length > 0) {
tabs.push( tabs.push(
<div key={`${address}-pm}`} className="tab-label"> <div key={`${address}-pm}`} className="tab-label">
Private messages <span>DIRECT MESSAGES ({privateChats[address].length})</span>
{/*<Button>+</Button>*/}
</div> </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 React, { memo } from 'react';
import AddChannel from 'components/modals/AddChannel';
import Confirm from 'components/modals/Confirm'; import Confirm from 'components/modals/Confirm';
const Modals = () => ( const Modals = () => (
<> <>
<AddChannel />
<Confirm /> <Confirm />
</> </>
); );

View File

@ -1,8 +1,10 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import Modal from 'react-modal'; 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 { getModals, closeModal } from 'state/modals';
import connect from 'utils/connect'; import connect from 'utils/connect';
import { bindActionCreators } from 'redux';
Modal.setAppElement('#root'); Modal.setAppElement('#root');
@ -47,15 +49,23 @@ export default function withModal({ name, ...modalProps }) {
); );
}; };
const mapState = createSelector( const mapState = createStructuredSelector({
getModals, isOpen: state => get(getModals(state), [name, 'isOpen'], false),
modals => modals[name] || { payload: {} } payload: state => get(getModals(state), [name, 'payload'], {}),
); ...modalProps.state
const mapDispatch = dispatch => ({
onRequestClose: () => dispatch(closeModal(name))
}); });
const mapDispatch = dispatch => {
let actions = { onRequestClose: () => dispatch(closeModal(name)) };
if (modalProps.actions) {
return {
...actions,
...bindActionCreators(modalProps.actions, dispatch)
};
}
return actions;
};
return connect( return connect(
mapState, mapState,
mapDispatch mapDispatch

View File

@ -1,7 +1,12 @@
import React from 'react'; import React from 'react';
import cn from 'classnames';
const Button = ({ children, category, ...props }) => ( const Button = ({ children, category, className, ...props }) => (
<button className={`button-${category}`} type="button" {...props}> <button
className={cn(`button-${category}`, className)}
type="button"
{...props}
>
{children} {children}
</button> </button>
); );

View File

@ -2,7 +2,7 @@ import { createStructuredSelector } from 'reselect';
import App from 'components/App'; import App from 'components/App';
import { getConnected } from 'state/app'; import { getConnected } from 'state/app';
import { getSortedChannels } from 'state/channels'; import { getSortedChannels } from 'state/channels';
import { getHasOpenModals } from 'state/modals'; import { openModal, getHasOpenModals } from 'state/modals';
import { getPrivateChats } from 'state/privateChats'; import { getPrivateChats } from 'state/privateChats';
import { getServers } from 'state/servers'; import { getServers } from 'state/servers';
import { getSelectedTab, select } from 'state/tab'; import { getSelectedTab, select } from 'state/tab';
@ -21,7 +21,7 @@ const mapState = createStructuredSelector({
hasOpenModals: getHasOpenModals hasOpenModals: getHasOpenModals
}); });
const mapDispatch = { push, select, hideMenu }; const mapDispatch = { push, select, hideMenu, openModal };
export default connect( export default connect(
mapState, mapState,

View File

@ -6,6 +6,8 @@ export const KICK = 'KICK';
export const PART = 'PART'; export const PART = 'PART';
export const SET_TOPIC = 'SET_TOPIC'; export const SET_TOPIC = 'SET_TOPIC';
export const CHANNEL_SEARCH = 'CHANNEL_SEARCH';
export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD'; export const INPUT_HISTORY_ADD = 'INPUT_HISTORY_ADD';
export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT'; export const INPUT_HISTORY_DECREMENT = 'INPUT_HISTORY_DECREMENT';
export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT'; export const INPUT_HISTORY_INCREMENT = 'INPUT_HISTORY_INCREMENT';
@ -64,6 +66,7 @@ export const socket = createSocketActions([
'cert_fail', 'cert_fail',
'cert_success', 'cert_success',
'channels', 'channels',
'channel_search',
'connection_update', 'connection_update',
'join', 'join',
'message', '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 { combineReducers } from 'redux';
import app from './app'; import app from './app';
import channels from './channels'; import channels from './channels';
import channelSearch from './channelSearch';
import input from './input'; import input from './input';
import messages from './messages'; import messages from './messages';
import modals from './modals'; import modals from './modals';
@ -19,6 +20,7 @@ export default function createReducer(router) {
router, router,
app, app,
channels, channels,
channelSearch,
input, input,
messages, messages,
modals, modals,

View File

@ -3,6 +3,7 @@ package server
import ( import (
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"unicode" "unicode"
@ -23,6 +24,7 @@ type ircHandler struct {
whois WhoisReply whois WhoisReply
userBuffers map[string][]string userBuffers map[string][]string
motdBuffer MOTD motdBuffer MOTD
listBuffer storage.ChannelListIndex
handlers map[string]func(*irc.Message) handlers map[string]func(*irc.Message)
} }
@ -183,6 +185,12 @@ func (i *ircHandler) info(msg *irc.Message) {
New: msg.Params[0], 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) 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{} 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) { func (i *ircHandler) badNick(msg *irc.Message) {
i.state.sendJSON("nick_fail", NickFail{ i.state.sendJSON("nick_fail", NickFail{
Server: i.client.Host, Server: i.client.Host,
@ -321,6 +357,8 @@ func (i *ircHandler) initHandlers() {
irc.ReplyMotdStart: i.motdStart, irc.ReplyMotdStart: i.motdStart,
irc.ReplyMotd: i.motd, irc.ReplyMotd: i.motd,
irc.ReplyEndOfMotd: i.motdEnd, irc.ReplyEndOfMotd: i.motdEnd,
irc.ReplyList: i.list,
irc.ReplyListEnd: i.listEnd,
irc.ErrErroneousNickname: i.badNick, irc.ErrErroneousNickname: i.badNick,
} }
} }

View File

@ -192,3 +192,14 @@ type Error struct {
Server string Server string
Message string Message string
} }
type ChannelSearch struct {
Server string
Q string
Start int
}
type ChannelSearchResult struct {
Results []*storage.ChannelListItem
Start int
}

View File

@ -3001,7 +3001,298 @@ func (v *ClientCert) UnmarshalJSON(data []byte) error {
func (v *ClientCert) UnmarshalEasyJSON(l *jlexer.Lexer) { func (v *ClientCert) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjson42239ddeDecodeGithubComKhliengDispatchServer26(l, v) 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() isTopLevel := in.IsStart()
if in.IsNull() { if in.IsNull() {
if isTopLevel { if isTopLevel {
@ -3034,7 +3325,7 @@ func easyjson42239ddeDecodeGithubComKhliengDispatchServer27(in *jlexer.Lexer, ou
in.Consumed() in.Consumed()
} }
} }
func easyjson42239ddeEncodeGithubComKhliengDispatchServer27(out *jwriter.Writer, in Away) { func easyjson42239ddeEncodeGithubComKhliengDispatchServer29(out *jwriter.Writer, in Away) {
out.RawByte('{') out.RawByte('{')
first := true first := true
_ = first _ = first
@ -3064,23 +3355,23 @@ func easyjson42239ddeEncodeGithubComKhliengDispatchServer27(out *jwriter.Writer,
// MarshalJSON supports json.Marshaler interface // MarshalJSON supports json.Marshaler interface
func (v Away) MarshalJSON() ([]byte, error) { func (v Away) MarshalJSON() ([]byte, error) {
w := jwriter.Writer{} w := jwriter.Writer{}
easyjson42239ddeEncodeGithubComKhliengDispatchServer27(&w, v) easyjson42239ddeEncodeGithubComKhliengDispatchServer29(&w, v)
return w.Buffer.BuildBytes(), w.Error return w.Buffer.BuildBytes(), w.Error
} }
// MarshalEasyJSON supports easyjson.Marshaler interface // MarshalEasyJSON supports easyjson.Marshaler interface
func (v Away) MarshalEasyJSON(w *jwriter.Writer) { func (v Away) MarshalEasyJSON(w *jwriter.Writer) {
easyjson42239ddeEncodeGithubComKhliengDispatchServer27(w, v) easyjson42239ddeEncodeGithubComKhliengDispatchServer29(w, v)
} }
// UnmarshalJSON supports json.Unmarshaler interface // UnmarshalJSON supports json.Unmarshaler interface
func (v *Away) UnmarshalJSON(data []byte) error { func (v *Away) UnmarshalJSON(data []byte) error {
r := jlexer.Lexer{Data: data} r := jlexer.Lexer{Data: data}
easyjson42239ddeDecodeGithubComKhliengDispatchServer27(&r, v) easyjson42239ddeDecodeGithubComKhliengDispatchServer29(&r, v)
return r.Error() return r.Error()
} }
// UnmarshalEasyJSON supports easyjson.Unmarshaler interface // UnmarshalEasyJSON supports easyjson.Unmarshaler interface
func (v *Away) UnmarshalEasyJSON(l *jlexer.Lexer) { func (v *Away) UnmarshalEasyJSON(l *jlexer.Lexer) {
easyjson42239ddeDecodeGithubComKhliengDispatchServer27(l, v) easyjson42239ddeDecodeGithubComKhliengDispatchServer29(l, v)
} }

View File

@ -15,6 +15,7 @@ import (
) )
var channelStore = storage.NewChannelStore() var channelStore = storage.NewChannelStore()
var channelIndexes = storage.NewChannelIndexManager()
type Dispatch struct { type Dispatch struct {
Store storage.Store Store storage.Store

View File

@ -13,10 +13,15 @@ import (
) )
const ( const (
// AnonymousUserExpiration is the time to wait before removing an anonymous
// user that has no irc or websocket connections
AnonymousUserExpiration = 1 * time.Minute AnonymousUserExpiration = 1 * time.Minute
) )
// State is the live state of a single user
type State struct { type State struct {
stateData
irc map[string]*irc.Client irc map[string]*irc.Client
connectionState map[string]irc.ConnectionState connectionState map[string]irc.ConnectionState
ircLock sync.Mutex ircLock sync.Mutex
@ -33,6 +38,7 @@ type State struct {
func NewState(user *storage.User, srv *Dispatch) *State { func NewState(user *storage.User, srv *Dispatch) *State {
return &State{ return &State{
stateData: stateData{m: map[string]interface{}{}},
irc: make(map[string]*irc.Client), irc: make(map[string]*irc.Client),
connectionState: make(map[string]irc.ConnectionState), connectionState: make(map[string]irc.ConnectionState),
ws: make(map[string]*wsConn), 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 { type stateStore struct {
states map[uint64]*State states map[uint64]*State
sessions map[string]*session.Session sessions map[string]*session.Session

View File

@ -97,6 +97,8 @@ func (h *wsHandler) connect(b []byte) {
var data Server var data Server
data.UnmarshalJSON(b) data.UnmarshalJSON(b)
data.Host = strings.ToLower(data.Host)
if _, ok := h.state.getIRC(data.Host); !ok { if _, ok := h.state.getIRC(data.Host); !ok {
log.Println(h.addr, "[IRC] Add server", data.Host) 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() { func (h *wsHandler) initHandlers() {
h.handlers = map[string]func([]byte){ h.handlers = map[string]func([]byte){
"connect": h.connect, "connect": h.connect,
@ -301,6 +326,7 @@ func (h *wsHandler) initHandlers() {
"fetch_messages": h.fetchMessages, "fetch_messages": h.fetchMessages,
"set_server_name": h.setServerName, "set_server_name": h.setServerName,
"settings_set": h.setSettings, "settings_set": h.setSettings,
"channel_search": h.channelSearch,
} }
} }

148
storage/channel_index.go Normal file
View 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()
}

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