Add manifest.json, icons and install button, flatten client/src
This commit is contained in:
parent
a219e689c1
commit
474afda9c2
105 changed files with 338 additions and 283 deletions
98
client/js/utils/Socket.js
Normal file
98
client/js/utils/Socket.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
import Backoff from 'backo';
|
||||
|
||||
export default class Socket {
|
||||
constructor(host) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
this.url = `${protocol}://${host}/ws${window.location.pathname}`;
|
||||
|
||||
this.connectTimeout = 20000;
|
||||
this.pingTimeout = 30000;
|
||||
this.backoff = new Backoff({
|
||||
min: 1000,
|
||||
max: 5000,
|
||||
jitter: 0.25
|
||||
});
|
||||
this.handlers = [];
|
||||
this.connected = false;
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.timeoutConnect = setTimeout(() => {
|
||||
this.ws.close();
|
||||
this.retry();
|
||||
}, this.connectTimeout);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
this.emit('_connected', true);
|
||||
clearTimeout(this.timeoutConnect);
|
||||
this.backoff.reset();
|
||||
this.setTimeoutPing();
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this.connected) {
|
||||
this.connected = false;
|
||||
this.emit('_connected', false);
|
||||
}
|
||||
clearTimeout(this.timeoutConnect);
|
||||
clearTimeout(this.timeoutPing);
|
||||
if (!this.closing) {
|
||||
this.retry();
|
||||
}
|
||||
this.closing = false;
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
clearTimeout(this.timeoutConnect);
|
||||
clearTimeout(this.timeoutPing);
|
||||
this.closing = true;
|
||||
this.ws.close();
|
||||
this.retry();
|
||||
};
|
||||
|
||||
this.ws.onmessage = e => {
|
||||
this.setTimeoutPing();
|
||||
|
||||
const msg = JSON.parse(e.data);
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
this.send('pong');
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit(msg.type, msg.data);
|
||||
};
|
||||
}
|
||||
|
||||
retry() {
|
||||
setTimeout(() => this.connect(), this.backoff.duration());
|
||||
}
|
||||
|
||||
send(type, data) {
|
||||
this.ws.send(JSON.stringify({ type, data }));
|
||||
}
|
||||
|
||||
setTimeoutPing() {
|
||||
clearTimeout(this.timeoutPing);
|
||||
this.timeoutPing = setTimeout(() => {
|
||||
this.closing = true;
|
||||
this.ws.close();
|
||||
this.connect();
|
||||
}, this.pingTimeout);
|
||||
}
|
||||
|
||||
onMessage(handler) {
|
||||
this.handlers.push(handler);
|
||||
}
|
||||
|
||||
emit(type, data) {
|
||||
for (let i = 0; i < this.handlers.length; i++) {
|
||||
this.handlers[i](type, data);
|
||||
}
|
||||
}
|
||||
}
|
120
client/js/utils/__tests__/util.test.js
Normal file
120
client/js/utils/__tests__/util.test.js
Normal file
|
@ -0,0 +1,120 @@
|
|||
import React from 'react';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
import { isChannel, isValidNick, isValidChannel, isValidUsername } from '..';
|
||||
import linkify from '../linkify';
|
||||
|
||||
const render = el => TestRenderer.create(el).toJSON();
|
||||
|
||||
describe('isChannel()', () => {
|
||||
it('it handles strings', () => {
|
||||
expect(isChannel('#cake')).toBe(true);
|
||||
expect(isChannel('cake')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles tab objects', () => {
|
||||
expect(isChannel({ name: '#cake' })).toBe(true);
|
||||
expect(isChannel({ name: 'cake' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidNick()', () => {
|
||||
it('validates nicks', () =>
|
||||
Object.entries({
|
||||
bob: true,
|
||||
'bob likes cake': false,
|
||||
'-bob': false,
|
||||
'bob.': false,
|
||||
'bob-': true,
|
||||
'1bob': false,
|
||||
'[bob}': true,
|
||||
'': false,
|
||||
' ': false
|
||||
}).forEach(([input, expected]) =>
|
||||
expect(isValidNick(input)).toBe(expected)
|
||||
));
|
||||
});
|
||||
|
||||
describe('isValidChannel()', () => {
|
||||
it('validates channels', () =>
|
||||
Object.entries({
|
||||
'#chan': true,
|
||||
'#cak e': false,
|
||||
'#cake:': false,
|
||||
'#[cake]': true,
|
||||
'#ca,ke': false,
|
||||
'': false,
|
||||
' ': false,
|
||||
cake: false
|
||||
}).forEach(([input, expected]) =>
|
||||
expect(isValidChannel(input)).toBe(expected)
|
||||
));
|
||||
|
||||
it('handles requirePrefix', () =>
|
||||
Object.entries({
|
||||
chan: true,
|
||||
'cak e': false,
|
||||
'#cake:': false,
|
||||
'#[cake]': true,
|
||||
'#ca,ke': false
|
||||
}).forEach(([input, expected]) =>
|
||||
expect(isValidChannel(input, false)).toBe(expected)
|
||||
));
|
||||
});
|
||||
|
||||
describe('isValidUsername()', () => {
|
||||
it('validates usernames', () =>
|
||||
Object.entries({
|
||||
bob: true,
|
||||
'bob likes cake': false,
|
||||
'-bob': true,
|
||||
'bob.': true,
|
||||
'bob-': true,
|
||||
'1bob': true,
|
||||
'[bob}': true,
|
||||
'': false,
|
||||
' ': false,
|
||||
'b@b': false
|
||||
}).forEach(([input, expected]) =>
|
||||
expect(isValidUsername(input)).toBe(expected)
|
||||
));
|
||||
});
|
||||
|
||||
describe('linkify()', () => {
|
||||
const proto = href => (href.indexOf('http') !== 0 ? `http://${href}` : href);
|
||||
const linkTo = href =>
|
||||
render(
|
||||
<a href={proto(href)} rel="noopener noreferrer" target="_blank">
|
||||
{href}
|
||||
</a>
|
||||
);
|
||||
|
||||
it('returns the arg when no matches are found', () =>
|
||||
[null, undefined, 10, false, true, 'just some text', ''].forEach(input =>
|
||||
expect(linkify(input)).toBe(input)
|
||||
));
|
||||
|
||||
it('linkifies text', () =>
|
||||
Object.entries({
|
||||
'google.com': linkTo('google.com'),
|
||||
'google.com stuff': [linkTo('google.com'), ' stuff'],
|
||||
'cake google.com stuff': ['cake ', linkTo('google.com'), ' stuff'],
|
||||
'cake google.com stuff https://google.com': [
|
||||
'cake ',
|
||||
linkTo('google.com'),
|
||||
' stuff ',
|
||||
linkTo('https://google.com')
|
||||
],
|
||||
'cake google.com stuff pie https://google.com ': [
|
||||
'cake ',
|
||||
linkTo('google.com'),
|
||||
' stuff pie ',
|
||||
linkTo('https://google.com'),
|
||||
' '
|
||||
],
|
||||
' google.com': [' ', linkTo('google.com')],
|
||||
'google.com ': [linkTo('google.com'), ' '],
|
||||
'/google.com?': ['/', linkTo('google.com'), '?']
|
||||
}).forEach(([input, expected]) =>
|
||||
expect(render(linkify(input))).toEqual(expected)
|
||||
));
|
||||
});
|
42
client/js/utils/color.js
Normal file
42
client/js/utils/color.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
/* eslint-disable no-bitwise */
|
||||
import { hsluvToHex } from 'hsluv';
|
||||
|
||||
//
|
||||
// github.com/sindresorhus/fnv1a
|
||||
//
|
||||
const OFFSET_BASIS_32 = 2166136261;
|
||||
|
||||
const fnv1a = string => {
|
||||
let hash = OFFSET_BASIS_32;
|
||||
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
hash ^= string.charCodeAt(i);
|
||||
|
||||
// 32-bit FNV prime: 2**24 + 2**8 + 0x93 = 16777619
|
||||
// Using bitshift for accuracy and performance. Numbers in JS suck.
|
||||
hash +=
|
||||
(hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
||||
}
|
||||
|
||||
return hash >>> 0;
|
||||
};
|
||||
|
||||
const colors = [];
|
||||
|
||||
for (let i = 0; i < 72; i++) {
|
||||
colors[i] = hsluvToHex([i * 5, 40, 50]);
|
||||
colors[i + 72] = hsluvToHex([i * 5, 70, 50]);
|
||||
colors[i + 144] = hsluvToHex([i * 5, 100, 50]);
|
||||
}
|
||||
|
||||
const cache = {};
|
||||
|
||||
export default function stringToRGB(str) {
|
||||
if (cache[str]) {
|
||||
return cache[str];
|
||||
}
|
||||
|
||||
const color = colors[fnv1a(str) % colors.length];
|
||||
cache[str] = color;
|
||||
return color;
|
||||
}
|
13
client/js/utils/connect.js
Normal file
13
client/js/utils/connect.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
const strictEqual = (a, b) => a === b;
|
||||
|
||||
export default (mapState, mapDispatch) =>
|
||||
connect(
|
||||
mapState,
|
||||
mapDispatch,
|
||||
null,
|
||||
{
|
||||
areStatePropsEqual: strictEqual
|
||||
}
|
||||
);
|
11
client/js/utils/createReducer.js
Normal file
11
client/js/utils/createReducer.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import produce from 'immer';
|
||||
import has from 'lodash/has';
|
||||
|
||||
export default function createReducer(initialState, handlers) {
|
||||
return function reducer(state = initialState, action) {
|
||||
if (has(handlers, action.type)) {
|
||||
return produce(state, draft => handlers[action.type](draft, action));
|
||||
}
|
||||
return state;
|
||||
};
|
||||
}
|
189
client/js/utils/index.js
Normal file
189
client/js/utils/index.js
Normal file
|
@ -0,0 +1,189 @@
|
|||
import padStart from 'lodash/padStart';
|
||||
|
||||
export { findBreakpoints, messageHeight } from './messageHeight';
|
||||
export { default as linkify } from './linkify';
|
||||
|
||||
export function normalizeChannel(channel) {
|
||||
if (channel.indexOf('#') !== 0) {
|
||||
return channel;
|
||||
}
|
||||
|
||||
return channel
|
||||
.split('#')
|
||||
.join('')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function isChannel(name) {
|
||||
// TODO: Handle other channel types
|
||||
if (typeof name === 'object') {
|
||||
({ name } = name);
|
||||
}
|
||||
return typeof name === 'string' && name[0] === '#';
|
||||
}
|
||||
|
||||
export function stringifyTab(server, name) {
|
||||
if (typeof server === 'object') {
|
||||
if (server.name) {
|
||||
return `${server.server};${server.name}`;
|
||||
}
|
||||
return server.server;
|
||||
}
|
||||
if (name) {
|
||||
return `${server};${name}`;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
function isString(s, maxLength) {
|
||||
if (!s || typeof s !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (maxLength && s.length > maxLength) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// RFC 2812
|
||||
// nickname = ( letter / special ) *( letter / digit / special / "-" )
|
||||
// letter = A-Z / a-z
|
||||
// digit = 0-9
|
||||
// special = "[", "]", "\", "`", "_", "^", "{", "|", "}"
|
||||
export function isValidNick(nick, maxLength = 30) {
|
||||
if (!isString(nick, maxLength)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < nick.length; i++) {
|
||||
const char = nick.charCodeAt(i);
|
||||
if (
|
||||
(i > 0 && char < 45) ||
|
||||
(char > 45 && char < 48) ||
|
||||
(char > 57 && char < 65) ||
|
||||
char > 125
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((i === 0 && char < 65) || char > 125) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// chanstring = any octet except NUL, BELL, CR, LF, " ", "," and ":"
|
||||
export function isValidChannel(channel, requirePrefix = true) {
|
||||
if (!isString(channel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (requirePrefix && channel[0] !== '#') {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < channel.length; i++) {
|
||||
const char = channel.charCodeAt(i);
|
||||
if (
|
||||
char === 0 ||
|
||||
char === 7 ||
|
||||
char === 10 ||
|
||||
char === 13 ||
|
||||
char === 32 ||
|
||||
char === 44 ||
|
||||
char === 58
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// user = any octet except NUL, CR, LF, " " and "@"
|
||||
export function isValidUsername(username) {
|
||||
if (!isString(username)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < username.length; i++) {
|
||||
const char = username.charCodeAt(i);
|
||||
if (
|
||||
char === 0 ||
|
||||
char === 10 ||
|
||||
char === 13 ||
|
||||
char === 32 ||
|
||||
char === 64
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isInt(i, min, max) {
|
||||
if (i < min || i > max || Math.floor(i) !== i) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function timestamp(date = new Date()) {
|
||||
const h = padStart(date.getHours(), 2, '0');
|
||||
const m = padStart(date.getMinutes(), 2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
export function stringWidth(str, font) {
|
||||
ctx.font = font;
|
||||
return ctx.measureText(str).width;
|
||||
}
|
||||
|
||||
export function measureScrollBarWidth() {
|
||||
const outer = document.createElement('div');
|
||||
outer.style.visibility = 'hidden';
|
||||
outer.style.width = '100px';
|
||||
|
||||
document.body.appendChild(outer);
|
||||
|
||||
const widthNoScroll = outer.offsetWidth;
|
||||
outer.style.overflow = 'scroll';
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.style.width = '100%';
|
||||
outer.appendChild(inner);
|
||||
|
||||
const widthWithScroll = inner.offsetWidth;
|
||||
|
||||
outer.parentNode.removeChild(outer);
|
||||
|
||||
return widthNoScroll - widthWithScroll;
|
||||
}
|
||||
|
||||
export function findIndex(arr, pred) {
|
||||
if (!arr) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (pred(arr[i])) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function find(arr, pred) {
|
||||
const i = findIndex(arr, pred);
|
||||
if (i !== -1) {
|
||||
return arr[i];
|
||||
}
|
||||
return null;
|
||||
}
|
67
client/js/utils/linkify.js
Normal file
67
client/js/utils/linkify.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import Autolinker from 'autolinker';
|
||||
import React from 'react';
|
||||
|
||||
const autolinker = new Autolinker({
|
||||
stripPrefix: false,
|
||||
stripTrailingSlash: false
|
||||
});
|
||||
|
||||
export default function linkify(text) {
|
||||
let matches = autolinker.parseText(text);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
let pos = 0;
|
||||
matches = autolinker.compactMatches(matches);
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const match = matches[i];
|
||||
|
||||
if (match.getType() === 'url') {
|
||||
if (match.offset > pos) {
|
||||
if (typeof result[result.length - 1] === 'string') {
|
||||
result[result.length - 1] += text.slice(pos, match.offset);
|
||||
} else {
|
||||
result.push(text.slice(pos, match.offset));
|
||||
}
|
||||
}
|
||||
|
||||
result.push(
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={match.getAnchorHref()}
|
||||
key={i}
|
||||
>
|
||||
{match.matchedText}
|
||||
</a>
|
||||
);
|
||||
} else if (typeof result[result.length - 1] === 'string') {
|
||||
result[result.length - 1] += text.slice(
|
||||
pos,
|
||||
match.offset + match.matchedText.length
|
||||
);
|
||||
} else {
|
||||
result.push(text.slice(pos, match.offset + match.matchedText.length));
|
||||
}
|
||||
|
||||
pos = match.offset + match.matchedText.length;
|
||||
}
|
||||
|
||||
if (pos < text.length) {
|
||||
if (typeof result[result.length - 1] === 'string') {
|
||||
result[result.length - 1] += text.slice(pos);
|
||||
} else {
|
||||
result.push(text.slice(pos));
|
||||
}
|
||||
}
|
||||
|
||||
if (result.length === 1) {
|
||||
return result[0];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
58
client/js/utils/messageHeight.js
Normal file
58
client/js/utils/messageHeight.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
const lineHeight = 24;
|
||||
const userListWidth = 200;
|
||||
const smallScreen = 600;
|
||||
|
||||
export function findBreakpoints(text) {
|
||||
const breakpoints = [];
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charAt(i);
|
||||
|
||||
if (char === ' ') {
|
||||
breakpoints.push({ end: i, next: i + 1 });
|
||||
} else if (char === '-' && i !== text.length - 1) {
|
||||
breakpoints.push({ end: i + 1, next: i + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return breakpoints;
|
||||
}
|
||||
|
||||
export function messageHeight(
|
||||
message,
|
||||
wrapWidth,
|
||||
charWidth,
|
||||
indent = 0,
|
||||
windowWidth
|
||||
) {
|
||||
let pad = (6 + (message.from ? message.from.length + 1 : 0)) * charWidth;
|
||||
let height = lineHeight + 8;
|
||||
|
||||
if (message.channel && windowWidth > smallScreen) {
|
||||
wrapWidth -= userListWidth;
|
||||
}
|
||||
|
||||
if (pad + message.length * charWidth < wrapWidth) {
|
||||
return height;
|
||||
}
|
||||
|
||||
const breaks = message.breakpoints;
|
||||
let prevBreak = 0;
|
||||
let prevPos = 0;
|
||||
|
||||
for (let i = 0; i < breaks.length; i++) {
|
||||
if (pad + (breaks[i].end - prevBreak) * charWidth >= wrapWidth) {
|
||||
prevBreak = prevPos;
|
||||
pad = indent;
|
||||
height += lineHeight;
|
||||
}
|
||||
|
||||
prevPos = breaks[i].next;
|
||||
}
|
||||
|
||||
if (pad + (message.length - prevBreak) * charWidth >= wrapWidth) {
|
||||
height += lineHeight;
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
111
client/js/utils/observe.js
Normal file
111
client/js/utils/observe.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
function subscribeArray(store, selectors, handler, init) {
|
||||
let state = store.getState();
|
||||
let prev = selectors.map(selector => selector(state));
|
||||
if (init) {
|
||||
handler(...prev);
|
||||
}
|
||||
|
||||
return store.subscribe(() => {
|
||||
state = store.getState();
|
||||
const next = [];
|
||||
let changed = false;
|
||||
|
||||
for (let i = 0; i < selectors.length; i++) {
|
||||
next[i] = selectors[i](state);
|
||||
if (next[i] !== prev[i]) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
handler(...next);
|
||||
prev = next;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(store, selector, handler, init) {
|
||||
if (Array.isArray(selector)) {
|
||||
return subscribeArray(store, selector, handler, init);
|
||||
}
|
||||
|
||||
let prev = selector(store.getState());
|
||||
if (init) {
|
||||
handler(prev);
|
||||
}
|
||||
|
||||
return store.subscribe(() => {
|
||||
const next = selector(store.getState());
|
||||
if (next !== prev) {
|
||||
handler(next);
|
||||
prev = next;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Handler gets called every time the selector(s) change
|
||||
//
|
||||
export function observe(store, selector, handler) {
|
||||
return subscribe(store, selector, handler, true);
|
||||
}
|
||||
|
||||
//
|
||||
// Handler gets called once the next time the selector(s) change
|
||||
//
|
||||
export function once(store, selector, handler) {
|
||||
let done = false;
|
||||
const unsubscribe = subscribe(store, selector, (...args) => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
handler(...args);
|
||||
}
|
||||
unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Handler gets called once when the predicate returns true, the predicate gets passed
|
||||
// the result of the selector(s), if no predicate is set it defaults to checking if the
|
||||
// selector(s) return something truthy
|
||||
//
|
||||
export function when(store, selector, predicate, handler) {
|
||||
if (arguments.length === 3) {
|
||||
handler = predicate;
|
||||
|
||||
if (Array.isArray(selector)) {
|
||||
predicate = (...args) => {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (!args[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
} else {
|
||||
predicate = o => o;
|
||||
}
|
||||
}
|
||||
|
||||
const state = store.getState();
|
||||
if (Array.isArray(selector)) {
|
||||
const val = selector.map(s => s(state));
|
||||
if (predicate(...val)) {
|
||||
return handler(...val);
|
||||
}
|
||||
} else {
|
||||
const val = selector(state);
|
||||
if (predicate(val)) {
|
||||
return handler(val);
|
||||
}
|
||||
}
|
||||
|
||||
let done = false;
|
||||
const unsubscribe = subscribe(store, selector, (...args) => {
|
||||
if (!done && predicate(...args)) {
|
||||
done = true;
|
||||
handler(...args);
|
||||
}
|
||||
unsubscribe();
|
||||
});
|
||||
}
|
110
client/js/utils/router.js
Normal file
110
client/js/utils/router.js
Normal file
|
@ -0,0 +1,110 @@
|
|||
import createHistory from 'history/createBrowserHistory';
|
||||
import UrlPattern from 'url-pattern';
|
||||
|
||||
const history = createHistory();
|
||||
|
||||
export const LOCATION_CHANGED = 'ROUTER_LOCATION_CHANGED';
|
||||
export const PUSH = 'ROUTER_PUSH';
|
||||
export const REPLACE = 'ROUTER_REPLACE';
|
||||
|
||||
export function locationChanged(route, params, location) {
|
||||
return {
|
||||
type: LOCATION_CHANGED,
|
||||
route,
|
||||
params,
|
||||
location
|
||||
};
|
||||
}
|
||||
|
||||
export function push(path) {
|
||||
return {
|
||||
type: PUSH,
|
||||
path
|
||||
};
|
||||
}
|
||||
|
||||
export function replace(path) {
|
||||
return {
|
||||
type: REPLACE,
|
||||
path
|
||||
};
|
||||
}
|
||||
|
||||
export function routeReducer(state = {}, action) {
|
||||
if (action.type === LOCATION_CHANGED) {
|
||||
return {
|
||||
route: action.route,
|
||||
params: action.params,
|
||||
location: action.location
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function routeMiddleware() {
|
||||
return next => action => {
|
||||
switch (action.type) {
|
||||
case PUSH:
|
||||
history.push(action.path);
|
||||
break;
|
||||
case REPLACE:
|
||||
history.replace(action.path);
|
||||
break;
|
||||
default:
|
||||
return next(action);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function decode(location) {
|
||||
location.pathname = decodeURIComponent(location.pathname);
|
||||
return location;
|
||||
}
|
||||
|
||||
function match(routes, location) {
|
||||
let params;
|
||||
for (let i = 0; i < routes.length; i++) {
|
||||
params = routes[i].pattern.match(location.pathname);
|
||||
if (params !== null) {
|
||||
const keys = Object.keys(params);
|
||||
for (let j = 0; j < keys.length; j++) {
|
||||
params[keys[j]] = decodeURIComponent(params[keys[j]]);
|
||||
}
|
||||
return locationChanged(routes[i].name, params, decode(location));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function initRouter(routes, store) {
|
||||
const patterns = [];
|
||||
const opts = {
|
||||
segmentValueCharset: 'a-zA-Z0-9-_.%'
|
||||
};
|
||||
|
||||
Object.keys(routes).forEach(name =>
|
||||
patterns.push({
|
||||
name,
|
||||
pattern: new UrlPattern(routes[name], opts)
|
||||
})
|
||||
);
|
||||
|
||||
let matched = match(patterns, history.location);
|
||||
if (matched) {
|
||||
store.dispatch(matched);
|
||||
} else {
|
||||
matched = { location: {} };
|
||||
}
|
||||
|
||||
history.listen(location => {
|
||||
const nextMatch = match(patterns, location);
|
||||
if (
|
||||
nextMatch &&
|
||||
nextMatch.location.pathname !== matched.location.pathname
|
||||
) {
|
||||
matched = nextMatch;
|
||||
store.dispatch(matched);
|
||||
}
|
||||
});
|
||||
}
|
12
client/js/utils/scrollPosition.js
Normal file
12
client/js/utils/scrollPosition.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
const positions = {};
|
||||
|
||||
export function getScrollPos(key) {
|
||||
if (key in positions) {
|
||||
return positions[key];
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function saveScrollPos(key, pos) {
|
||||
positions[key] = pos;
|
||||
}
|
45
client/js/utils/size.js
Normal file
45
client/js/utils/size.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
let width, height;
|
||||
const listeners = [];
|
||||
|
||||
function update() {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
|
||||
for (let i = 0; i < listeners.length; i++) {
|
||||
listeners[i](width, height);
|
||||
}
|
||||
}
|
||||
|
||||
let resizeRAF;
|
||||
|
||||
function resize() {
|
||||
if (resizeRAF) {
|
||||
window.cancelAnimationFrame(resizeRAF);
|
||||
}
|
||||
resizeRAF = window.requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
update();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
export function windowWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
export function windowHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
export function addResizeListener(f, init) {
|
||||
listeners.push(f);
|
||||
if (init) {
|
||||
f(width, height);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeResizeListener(f) {
|
||||
const i = listeners.indexOf(f);
|
||||
if (i > -1) {
|
||||
listeners.splice(i, 1);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue