├── pyramid-starter.bat ├── scripts ├── vacuumDatabase.sql ├── clearDatabaseLogs.sql ├── vacuumDatabase.sh ├── updateDbIndexes.sh ├── updateDbSchema-2017-05-09.sh ├── updateDbSchema-2017-05-11.sh ├── updateDbSchema-2017-06-24.sh ├── updateDbSchema-2017-06-27.sh ├── updateDbSchema-2017-07-10.sh ├── updateDbSchema-2017-06-27.sql ├── clearLogFolder.sh ├── updateDbIndexes.sql ├── clearDatabaseLogs.sh ├── updateDbSchema-2017-06-15.sh ├── updateDbSchema-2017-05-11.sql ├── updateDbSchema-2017-06-24.sql ├── bumpBuildNumber.js ├── updateDbSchema-2017-06-15.sql ├── updateDbSchema-2017-07-10.sql ├── updateLocalDatestamps.js ├── updateDbSchema-2017-05-09.sql ├── convertConfigToDb.js └── setHttpsCerts.js ├── public ├── src │ ├── js │ │ ├── buildNumber.js │ │ ├── lib │ │ │ ├── refEls.js │ │ │ ├── users.js │ │ │ ├── timeZones.js │ │ │ ├── scrolling.js │ │ │ ├── connectionStatus.js │ │ │ ├── ircConfigs.js │ │ │ ├── posting.js │ │ │ ├── emojis.js │ │ │ ├── chatEvents.js │ │ │ ├── conversations.js │ │ │ ├── messageCaches.js │ │ │ ├── channelNames.js │ │ │ ├── visualBehavior.js │ │ │ └── dataExpiration.js │ │ ├── sagas.js │ │ ├── reducers │ │ │ ├── onlineFriends.js │ │ │ ├── token.js │ │ │ ├── appConfig.js │ │ │ ├── logDetails.js │ │ │ ├── systemInfo.js │ │ │ ├── lastSeenUsers.js │ │ │ ├── lastSeenChannels.js │ │ │ ├── unseenHighlights.js │ │ │ ├── multiServerChannels.js │ │ │ ├── channelUserLists.js │ │ │ ├── connectionStatus.js │ │ │ ├── unseenConversations.js │ │ │ ├── viewState.js │ │ │ ├── nicknames.js │ │ │ ├── ircConfigs.js │ │ │ ├── lineInfo.js │ │ │ ├── deviceState.js │ │ │ ├── channelData.js │ │ │ ├── serverData.js │ │ │ ├── friendsList.js │ │ │ ├── offlineMessages.js │ │ │ ├── categoryCaches.js │ │ │ ├── logFiles.js │ │ │ ├── index.js │ │ │ ├── userCaches.js │ │ │ └── channelCaches.js │ │ ├── chatview │ │ │ ├── chatline │ │ │ │ ├── ChatLinePrefix.jsx │ │ │ │ ├── ChatUserNoticeLinePrefix.jsx │ │ │ │ ├── LogLine.jsx │ │ │ │ ├── ChatConnectionEventLine.jsx │ │ │ │ ├── ChatUsername.jsx │ │ │ │ ├── Emoji.jsx │ │ │ │ ├── ChatUserEventLine.jsx │ │ │ │ ├── ChatHighlightedLine.jsx │ │ │ │ └── ChatOfflineResendButton.jsx │ │ │ ├── NoChatView.jsx │ │ │ ├── ChatHighlightsControls.jsx │ │ │ ├── ChatSystemLogControls.jsx │ │ │ ├── ChatViewFooter.jsx │ │ │ ├── ChatUserListControl.jsx │ │ │ ├── ChatViewWrapper.jsx │ │ │ ├── ChatViewLogBrowser.jsx │ │ │ ├── ChatLines.jsx │ │ │ └── ChannelUserList.jsx │ │ ├── components │ │ │ ├── Loader.jsx │ │ │ ├── Header.jsx │ │ │ ├── AccessKeys.jsx │ │ │ ├── VersionNumber.jsx │ │ │ ├── App.jsx │ │ │ ├── ChannelLink.jsx │ │ │ ├── ChatViewLink.jsx │ │ │ ├── TimedChannelItem.jsx │ │ │ ├── HighlightsLink.jsx │ │ │ ├── UserList.jsx │ │ │ ├── SidebarUserList.jsx │ │ │ ├── UserLink.jsx │ │ │ └── TimedUserItem.jsx │ │ ├── sagas │ │ │ ├── appConfig.js │ │ │ ├── ircConfigs.js │ │ │ └── channelCaches.js │ │ ├── store.js │ │ ├── constants.js │ │ ├── twitch │ │ │ ├── TwitchCheermote.jsx │ │ │ └── TwitchChannelFlags.jsx │ │ ├── settingsview │ │ │ ├── SettingsFriendsView.jsx │ │ │ └── SettingsView.jsx │ │ └── actionTypes.js │ └── scss │ │ ├── config.scss │ │ ├── login.scss │ │ ├── error.scss │ │ ├── site.scss │ │ ├── container.scss │ │ ├── header.scss │ │ ├── logbrowser.scss │ │ ├── multichat.scss │ │ ├── loader.scss │ │ ├── connectioninfo.scss │ │ ├── switcher.scss │ │ ├── menu.scss │ │ ├── welcome.scss │ │ ├── basic.scss │ │ ├── mainview.scss │ │ ├── tooltips.scss │ │ └── itemlist.scss ├── favicon.ico └── img │ ├── pyramid-icon.png │ ├── diamond.svg │ ├── close.svg │ ├── menu.svg │ └── cog.svg ├── pyramid-empty.db ├── pyramid-starter.sh ├── .npmrc ├── views ├── unauthorized.ejs ├── error.ejs ├── login.ejs ├── layout.ejs └── index.ejs ├── server ├── main │ ├── viewState.js │ ├── channelData.js │ ├── ircConnectionState.js │ ├── serverData.js │ ├── unseenHighlights.js │ ├── unseenConversations.js │ ├── nicknames.js │ ├── friends.js │ └── ircControl.js ├── util │ ├── events.js │ ├── usernames.js │ ├── relationships.js │ ├── files.js │ ├── time.js │ ├── tokens.js │ ├── strings.js │ ├── routing.js │ ├── passwords.js │ └── channels.js ├── routes │ ├── logout.js │ ├── login.js │ └── home.js ├── routes.js ├── defaults.js ├── constants.js └── plugins.js ├── .gitignore ├── .eslintrc.server.js ├── .eslintrc.js ├── serverplugins └── twitch │ ├── users.js │ ├── badges.js │ ├── groupChats.js │ ├── httpRequests.js │ ├── userStates.js │ ├── emotes.js │ └── util.js ├── pyramid.js ├── LICENSE ├── webpack.dev.config.js └── webpack.prod.config.js /pyramid-starter.bat: -------------------------------------------------------------------------------- 1 | npm start 2 | -------------------------------------------------------------------------------- /scripts/vacuumDatabase.sql: -------------------------------------------------------------------------------- 1 | VACUUM; 2 | -------------------------------------------------------------------------------- /public/src/js/buildNumber.js: -------------------------------------------------------------------------------- 1 | export default 97; 2 | -------------------------------------------------------------------------------- /scripts/clearDatabaseLogs.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM lines; 2 | VACUUM; 3 | -------------------------------------------------------------------------------- /scripts/vacuumDatabase.sh: -------------------------------------------------------------------------------- 1 | sqlite3 ../data/pyramid.db < vacuumDatabase.sql 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graulund/pyramid/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/src/scss/config.scss: -------------------------------------------------------------------------------- 1 | $mobile-media: "only screen and (max-width: 767px)"; 2 | -------------------------------------------------------------------------------- /pyramid-empty.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graulund/pyramid/HEAD/pyramid-empty.db -------------------------------------------------------------------------------- /scripts/updateDbIndexes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sqlite3 ../data/pyramid.db < updateDbIndexes.sql 3 | -------------------------------------------------------------------------------- /public/src/scss/login.scss: -------------------------------------------------------------------------------- 1 | .login-form { 2 | margin: 50px 30px; 3 | text-align: center; 4 | } 5 | -------------------------------------------------------------------------------- /public/img/pyramid-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graulund/pyramid/HEAD/public/img/pyramid-icon.png -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-05-09.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sqlite3 ../data/pyramid.db < updateDbSchema-2017-05-09.sql 3 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-05-11.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sqlite3 ../data/pyramid.db < updateDbSchema-2017-05-11.sql 3 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-06-24.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sqlite3 ../data/pyramid.db < updateDbSchema-2017-06-24.sql 3 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-06-27.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sqlite3 ../data/pyramid.db < updateDbSchema-2017-06-27.sql 3 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-07-10.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | sqlite3 ../data/pyramid.db < updateDbSchema-2017-07-10.sql 3 | -------------------------------------------------------------------------------- /pyramid-starter.sh: -------------------------------------------------------------------------------- 1 | forever start -l pyramid-forever.log -o pyramid-out.log -e pyramid-err.log pyramid/pyramid.js 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Until this is solved, due to issue with node-pre-gyp: https://github.com/npm/npm/issues/16728 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-06-27.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX "serverChannel"; 2 | CREATE UNIQUE INDEX "serverChannel" ON "ircChannels" ("serverId","channelType","name"); 3 | -------------------------------------------------------------------------------- /public/img/diamond.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/unauthorized.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Unauthorized

3 |

Sorry, that wasn’t quite right.

4 |

Try again

5 |
6 | -------------------------------------------------------------------------------- /public/src/scss/error.scss: -------------------------------------------------------------------------------- 1 | .error { 2 | background-color: #eee; 3 | padding: 20px; 4 | 5 | h1 { 6 | font-size: 125%; 7 | } 8 | 9 | h2 { 10 | font-size: 100%; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /views/error.ejs: -------------------------------------------------------------------------------- 1 |
2 |

An error occurred: <%= message %>

3 |

Sorry about that.

4 | 8 |
9 | -------------------------------------------------------------------------------- /scripts/clearLogFolder.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo -n "WARNING: You are about to delete all logs in your Pyramid log folder. Do you wish to continue? [y/n] " 3 | read answer 4 | if echo "$answer" | grep -iq "^y" ;then 5 | rm -rf ../public/data/logs/* 6 | fi 7 | -------------------------------------------------------------------------------- /scripts/updateDbIndexes.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX "username"; 2 | DROP INDEX "date"; 3 | DROP INDEX "channelId"; 4 | CREATE INDEX "channelDateTime" ON "lines" ("channelId","time","date"); 5 | CREATE INDEX "usernameDateTime" ON "lines" ("time","date","username"); 6 | VACUUM; 7 | -------------------------------------------------------------------------------- /public/src/js/lib/refEls.js: -------------------------------------------------------------------------------- 1 | // Tools for reference elements 2 | 3 | export function refElSetter (name) { 4 | // You're meant to bind this returned function to another "this" 5 | return function (el) { 6 | this.els = this.els || {}; 7 | this.els[name] = el; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /public/src/js/lib/users.js: -------------------------------------------------------------------------------- 1 | import store from "../store"; 2 | 3 | export function getUserInfo(username) { 4 | let state = store.getState(); 5 | let user = state.lastSeenUsers[username]; 6 | 7 | if (user) { 8 | return { username, ...user }; 9 | } 10 | 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /public/src/js/sagas.js: -------------------------------------------------------------------------------- 1 | import appConfig from "./sagas/appConfig"; 2 | import channelCaches from "./sagas/channelCaches"; 3 | import ircConfigs from "./sagas/ircConfigs"; 4 | 5 | const sagas = [ 6 | appConfig, 7 | channelCaches, 8 | ircConfigs 9 | ]; 10 | 11 | export default sagas; 12 | -------------------------------------------------------------------------------- /server/main/viewState.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | var currentViewState = {}; 4 | 5 | const storeViewState = function(viewState) { 6 | currentViewState = _.assign({}, currentViewState, viewState); 7 | }; 8 | 9 | module.exports = { 10 | currentViewState: () => currentViewState, 11 | storeViewState 12 | }; 13 | -------------------------------------------------------------------------------- /public/src/js/reducers/onlineFriends.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const onlineFriendsInitialState = {}; 4 | 5 | export default function (state = onlineFriendsInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.onlineFriends.SET: 9 | return action.data; 10 | } 11 | 12 | return state; 13 | } 14 | -------------------------------------------------------------------------------- /public/src/js/reducers/token.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const tokenInitialState = null; 4 | 5 | export default function (state = tokenInitialState, action) { 6 | 7 | // This always replaces 8 | switch (action.type) { 9 | case actionTypes.token.SET: 10 | return action.data; 11 | } 12 | 13 | return state; 14 | } 15 | -------------------------------------------------------------------------------- /server/util/events.js: -------------------------------------------------------------------------------- 1 | const constants = require("../constants"); 2 | 3 | const isJoinEvent = function(event) { 4 | return event && event.type === "join"; 5 | }; 6 | 7 | const isPartEvent = function(event) { 8 | return event && constants.PART_EVENT_TYPES.indexOf(event.type) >= 0; 9 | }; 10 | 11 | module.exports = { 12 | isJoinEvent, 13 | isPartEvent 14 | }; 15 | -------------------------------------------------------------------------------- /server/routes/logout.js: -------------------------------------------------------------------------------- 1 | const routeUtils = require("../util/routing"); 2 | 3 | module.exports = function(req, res) { 4 | // Set already expired cookie in order to remove the cookie 5 | routeUtils.setTokenCookie( 6 | res, 7 | "k", 8 | { 9 | httpOnly: true, 10 | expires: new Date("1997-01-01 00:00:00") 11 | } 12 | ); 13 | res.redirect("/login"); 14 | }; 15 | -------------------------------------------------------------------------------- /public/src/js/reducers/appConfig.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const appConfigInitialState = {}; 4 | 5 | export default function (state = appConfigInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.appConfig.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/logDetails.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const logDetailsInitialState = {}; 4 | 5 | export default function (state = logDetailsInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.logDetails.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/systemInfo.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const systemInfoInitialState = {}; 4 | 5 | export default function (state = systemInfoInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.systemInfo.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/lastSeenUsers.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const lastSeenInitialState = {}; 4 | 5 | export default function (state = lastSeenInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.lastSeenUsers.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/lastSeenChannels.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const lastSeenInitialState = {}; 4 | 5 | export default function (state = lastSeenInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.lastSeenChannels.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/unseenHighlights.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const unseenHighlightsInitialState = null; 4 | 5 | export default function (state = unseenHighlightsInitialState, action) { 6 | 7 | // This always replaces 8 | switch (action.type) { 9 | case actionTypes.unseenHighlights.SET: 10 | return action.data; 11 | } 12 | 13 | return state; 14 | } 15 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatLinePrefix.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | function ChatLinePrefix(props) { 5 | if (props.children) { 6 | return
{ props.children }
; 7 | } 8 | 9 | return null; 10 | } 11 | 12 | ChatLinePrefix.propTypes = { 13 | children: PropTypes.node 14 | }; 15 | 16 | export default ChatLinePrefix; 17 | -------------------------------------------------------------------------------- /public/src/js/components/Loader.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | class Loader extends PureComponent { 5 | render() { 6 | const { className = "loader" } = this.props; 7 | 8 | return
; 9 | } 10 | } 11 | 12 | Loader.propTypes = { 13 | className: PropTypes.string 14 | }; 15 | 16 | export default Loader; 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/multiServerChannels.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const multiServerChannelsInitialState = []; 4 | 5 | export default function (state = multiServerChannelsInitialState, action) { 6 | 7 | // This always replaces 8 | switch (action.type) { 9 | case actionTypes.multiServerChannels.SET: 10 | return action.data; 11 | } 12 | 13 | return state; 14 | } 15 | -------------------------------------------------------------------------------- /public/src/js/reducers/channelUserLists.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const channelUserListsInitialState = {}; 4 | 5 | export default function (state = channelUserListsInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.channelUserLists.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/connectionStatus.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const connectionStatusInitialState = {}; 4 | 5 | export default function (state = connectionStatusInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.connectionStatus.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | } 14 | 15 | return state; 16 | } 17 | -------------------------------------------------------------------------------- /public/src/js/reducers/unseenConversations.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const unseenConversationsInitialState = null; 4 | 5 | export default function (state = unseenConversationsInitialState, action) { 6 | 7 | // This always replaces 8 | switch (action.type) { 9 | case actionTypes.unseenConversations.SET: 10 | return action.data; 11 | } 12 | 13 | return state; 14 | } 15 | -------------------------------------------------------------------------------- /public/src/js/reducers/viewState.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const viewStateInitialState = { 4 | sidebarVisible: true 5 | }; 6 | 7 | export default function (state = viewStateInitialState, action) { 8 | 9 | switch (action.type) { 10 | case actionTypes.viewState.UPDATE: 11 | return { 12 | ...state, 13 | ...action.data 14 | }; 15 | } 16 | 17 | return state; 18 | } 19 | -------------------------------------------------------------------------------- /scripts/clearDatabaseLogs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | command -v sqlite3 >/dev/null 2>&1 || { echo >&2 "This script requires the sqlite3 command line tool, but it's not installed. Aborting."; exit 1; } 3 | 4 | echo -n "WARNING: You are about to delete all logs in your Pyramid database. Do you wish to continue? [y/n] " 5 | read answer 6 | if echo "$answer" | grep -iq "^y" ;then 7 | sqlite3 ../data/pyramid.db < clearDatabaseLogs.sql 8 | fi 9 | -------------------------------------------------------------------------------- /public/src/js/reducers/nicknames.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const nicknamesInitialState = {}; 4 | 5 | export default function (state = nicknamesInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.nicknames.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | 14 | case actionTypes.nicknames.SET: 15 | return action.data; 16 | } 17 | 18 | return state; 19 | } 20 | -------------------------------------------------------------------------------- /public/src/js/reducers/ircConfigs.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const ircConfigsInitialState = {}; 4 | 5 | export default function (state = ircConfigsInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.ircConfigs.UPDATE: 9 | return { 10 | ...state, 11 | ...action.data 12 | }; 13 | 14 | case actionTypes.ircConfigs.SET: 15 | return action.data; 16 | } 17 | 18 | return state; 19 | } 20 | -------------------------------------------------------------------------------- /public/src/js/reducers/lineInfo.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const lineInfoInitialState = {}; 4 | 5 | export default function (state = lineInfoInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.lineInfo.CLEAR: 9 | return lineInfoInitialState; 10 | 11 | case actionTypes.lineInfo.UPDATE: 12 | return { 13 | ...state, 14 | ...action.data 15 | }; 16 | } 17 | 18 | return state; 19 | } 20 | -------------------------------------------------------------------------------- /public/src/js/reducers/deviceState.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const deviceStateInitialState = { 4 | inFocus: true, 5 | isTouchDevice: false, 6 | visible: true 7 | }; 8 | 9 | export default function (state = deviceStateInitialState, action) { 10 | 11 | switch (action.type) { 12 | case actionTypes.deviceState.UPDATE: 13 | return { 14 | ...state, 15 | ...action.data 16 | }; 17 | } 18 | 19 | return state; 20 | } 21 | -------------------------------------------------------------------------------- /public/img/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/login.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Welcome to Pyramid!

3 |

Please type the password to proceed.

4 |

5 |

6 |

7 |
8 | 9 | -------------------------------------------------------------------------------- /public/src/scss/site.scss: -------------------------------------------------------------------------------- 1 | @import "config"; 2 | @import "basic"; 3 | @import "error"; 4 | @import "container"; 5 | @import "loader"; 6 | @import "itemlist"; 7 | @import "switcher"; 8 | @import "menu"; 9 | @import "connectioninfo"; 10 | @import "header"; 11 | @import "login"; 12 | @import "sidebar"; 13 | @import "mainview"; 14 | @import "chatview"; 15 | @import "logbrowser"; 16 | @import "settingsview"; 17 | @import "multichat"; 18 | @import "welcome"; 19 | @import "tooltips"; 20 | @import "darkmode"; 21 | -------------------------------------------------------------------------------- /server/util/usernames.js: -------------------------------------------------------------------------------- 1 | const long = require("long"); 2 | 3 | const getUserColorNumber = function(username) { 4 | if (username) { 5 | username = username.toLowerCase(); 6 | 7 | var hashedValue = new long(0); 8 | 9 | for (var i = 0; i < username.length; i++) { 10 | var c = username.charCodeAt(i); 11 | hashedValue = hashedValue.shiftLeft(6).add(hashedValue).add(c); 12 | } 13 | 14 | return hashedValue.mod(30).toNumber(); 15 | } 16 | 17 | return null; 18 | }; 19 | 20 | module.exports = { 21 | getUserColorNumber 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Irrelevant folders 2 | exp/ 3 | node_modules/ 4 | .idea/ 5 | 6 | # Run-time data 7 | config.js 8 | data/ 9 | public/data/ 10 | *.pid 11 | *.seed 12 | npm-debug.log 13 | 14 | # OS 15 | .DS_Store 16 | .AppleDouble 17 | .LSOverride 18 | ._* 19 | .Spotlight-V100 20 | .Trashes 21 | .AppleDB 22 | .AppleDesktop 23 | Network Trash Folder 24 | Temporary Items 25 | .apdisk 26 | Thumbs.db 27 | ehthumbs.db 28 | Desktop.ini 29 | $RECYCLE.BIN/ 30 | [._]*.s[a-w][a-z] 31 | [._]s[a-w][a-z] 32 | *.un~ 33 | Session.vim 34 | .netrwhist 35 | *~ 36 | .directory 37 | -------------------------------------------------------------------------------- /public/src/js/sagas/appConfig.js: -------------------------------------------------------------------------------- 1 | import { take } from "redux-saga/effects"; 2 | 3 | import * as actionTypes from "../actionTypes"; 4 | import { setDarkModeStatus } from "../lib/visualBehavior"; 5 | 6 | export default function* () { 7 | while (true) { 8 | var action = yield take(actionTypes.appConfig.UPDATE); 9 | 10 | // Handle new settings 11 | 12 | // Add/remove dark mode 13 | if (action.data && "enableDarkMode" in action.data) { 14 | const { enableDarkMode } = action.data; 15 | setDarkModeStatus(enableDarkMode); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/src/js/reducers/channelData.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const channelDataInitialState = {}; 4 | 5 | export default function (state = channelDataInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.channelData.UPDATE: { 9 | let { channel, data } = action; 10 | if (data) { 11 | let current = state[channel] || {}; 12 | return { 13 | ...state, 14 | [channel]: { 15 | ...current, 16 | ...data 17 | } 18 | }; 19 | } 20 | } 21 | } 22 | 23 | return state; 24 | } 25 | -------------------------------------------------------------------------------- /public/src/scss/container.scss: -------------------------------------------------------------------------------- 1 | .app-container { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100vh; 5 | 6 | > * { 7 | flex: 0 0 auto; 8 | } 9 | 10 | &__inner { 11 | flex: 1 1 auto; 12 | 13 | position: relative; 14 | } 15 | 16 | &__iinner { 17 | position: absolute; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | 23 | display: flex; 24 | 25 | > * { 26 | flex: 0 0 auto; 27 | } 28 | } 29 | 30 | &__iinner &__main { 31 | flex: 1 1 auto; 32 | } 33 | 34 | &__main { 35 | height: 100%; 36 | overflow: hidden; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/src/scss/header.scss: -------------------------------------------------------------------------------- 1 | .global-header { 2 | display: none; 3 | 4 | @media #{$mobile-media} { 5 | display: block; 6 | } 7 | 8 | .highlightslink { 9 | display: none; 10 | pointer-events: none; 11 | 12 | &--highlighted { 13 | display: block; 14 | position: absolute; 15 | top: 4px; 16 | left: 18px; 17 | z-index: 10; 18 | 19 | background-color: #b9af7c; 20 | color: #fff; 21 | border: 2px solid #fff; 22 | border-radius: 100px; 23 | line-height: 1.2em; 24 | min-width: 0.8em; 25 | padding: 0 0.2em; 26 | text-align: center; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | // PYRAMID 2 | // Routes module 3 | 4 | module.exports = function(app, main) { 5 | 6 | // Login page 7 | 8 | const login = require("./routes/login")(main); 9 | app.get("/login", login.get); 10 | app.post("/login", login.post); 11 | 12 | // Logout page 13 | 14 | app.get("/logout", require("./routes/logout")); 15 | 16 | // Welcome page 17 | 18 | const welcome = require("./routes/welcome")(main); 19 | app.get("/welcome", welcome.get); 20 | app.post("/welcome", welcome.post); 21 | 22 | // Main page 23 | 24 | app.get("*", require("./routes/home")(main)); 25 | }; 26 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatUserNoticeLinePrefix.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ChatLinePrefix from "./ChatLinePrefix.jsx"; 5 | 6 | class ChatUserNoticeLinePrefix extends PureComponent { 7 | render() { 8 | let { tags } = this.props; 9 | let systemMessage = tags && tags["system-msg"]; 10 | 11 | return { systemMessage }; 12 | } 13 | } 14 | 15 | ChatUserNoticeLinePrefix.propTypes = { 16 | tags: PropTypes.object.isRequired 17 | }; 18 | 19 | export default ChatUserNoticeLinePrefix; 20 | -------------------------------------------------------------------------------- /.eslintrc.server.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended"], 7 | "parser": "babel-eslint", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | } 12 | }, 13 | "plugins": [ 14 | "standard", 15 | "promise" 16 | ], 17 | "rules": { 18 | "eqeqeq": [2, "smart"], 19 | "indent": 0, 20 | "no-constant-condition": 0, 21 | "no-console": 0, 22 | "no-tabs": 0, 23 | "quotes": [2, "double"], 24 | "react/jsx-no-bind": 2, 25 | "semi": [2, "always"], 26 | "spaced-comment": 0 27 | } 28 | }; -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended"], 7 | "parser": "babel-eslint", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | } 12 | }, 13 | "plugins": [ 14 | "standard", 15 | "promise", 16 | "react" 17 | ], 18 | "rules": { 19 | "eqeqeq": [2, "smart"], 20 | "indent": 0, 21 | "no-constant-condition": 0, 22 | "no-console": 0, 23 | "no-tabs": 0, 24 | "quotes": [2, "double"], 25 | "react/jsx-no-bind": 2, 26 | "semi": [2, "always"], 27 | "spaced-comment": 0 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /public/src/js/reducers/serverData.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const serverDataInitialState = {}; 4 | 5 | export default function (state = serverDataInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.serverData.SET: 9 | return action.data; 10 | 11 | case actionTypes.serverData.UPDATE: { 12 | let { server, data } = action; 13 | if (data) { 14 | let current = state[server] || {}; 15 | return { 16 | ...state, 17 | [server]: { 18 | ...current, 19 | ...data 20 | } 21 | }; 22 | } 23 | } 24 | } 25 | 26 | return state; 27 | } 28 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-06-15.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "WARNING: Depending on how many logs you've got in your database, this update could take a LONG time." 4 | echo "" 5 | echo "If you want to speed it up, you can delete all of your logs in the database by aborting and running this first: ./clearDatabaseLogs.sh" 6 | echo "" 7 | echo "Otherwise you'll just have to wait it out. With tons of data, it might take up to an hour before this command is done." 8 | 9 | echo -n " Do you want to continue? [y/n] " 10 | read answer 11 | if echo "$answer" | grep -iq "^y" ;then 12 | sqlite3 ../data/pyramid.db < updateDbSchema-2017-06-15.sql 13 | fi 14 | -------------------------------------------------------------------------------- /server/main/channelData.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | module.exports = function(io) { 4 | 5 | var channelData = {}; 6 | 7 | const clearChannelData = function() { 8 | channelData = {}; 9 | }; 10 | 11 | const getChannelData = function(channel) { 12 | return channelData[channel]; 13 | }; 14 | 15 | const setChannelData = function(channel, data) { 16 | let current = channelData[channel] || {}; 17 | channelData[channel] = _.assign(current, data); 18 | 19 | if (io) { 20 | io.emitDataToChannel(channel, data); 21 | } 22 | }; 23 | 24 | return { 25 | clearChannelData, 26 | getChannelData, 27 | setChannelData 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /public/src/js/reducers/friendsList.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | 3 | const friendsLevelInitialState = {}; 4 | 5 | export default function (state = friendsLevelInitialState, action) { 6 | 7 | switch (action.type) { 8 | case actionTypes.friendsList.UPDATE: 9 | if (action.level) { 10 | const existingLevelData = state[action.level] || []; 11 | 12 | return { 13 | ...state, 14 | [action.level]: [ 15 | ...existingLevelData, 16 | ...action.data 17 | ] 18 | }; 19 | } 20 | return state; 21 | 22 | case actionTypes.friendsList.SET: 23 | return action.data; 24 | } 25 | 26 | return state; 27 | } 28 | -------------------------------------------------------------------------------- /public/src/js/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import HighlightsLink from "./HighlightsLink.jsx"; 4 | import store from "../store"; 5 | import actions from "../actions"; 6 | 7 | function openSidebar() { 8 | store.dispatch(actions.viewState.update({ sidebarVisible: true })); 9 | } 10 | 11 | const Header = function() { 12 | return ( 13 |
14 | 18 | Sidebar 19 | 20 | 21 |
22 | ); 23 | }; 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /public/src/js/sagas/ircConfigs.js: -------------------------------------------------------------------------------- 1 | import { put, take } from "redux-saga/effects"; 2 | 3 | import * as actionTypes from "../actionTypes"; 4 | import actions from "../actions"; 5 | import { calibrateMultiServerChannels } from "../lib/ircConfigs"; 6 | 7 | export default function* () { 8 | while (true) { 9 | var action = yield take([ 10 | actionTypes.ircConfigs.SET, actionTypes.ircConfigs.UPDATE 11 | ]); 12 | 13 | // Handle changes in IRC config: Update multiserver channels 14 | // TODO: Does this work properly with update? Do we have all the data? 15 | yield put(actions.multiServerChannels.set( 16 | calibrateMultiServerChannels(action.data) 17 | )); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/util/relationships.js: -------------------------------------------------------------------------------- 1 | const constants = require("../constants"); 2 | 3 | const getRelationship = function(username, friendsList) { 4 | 5 | if (username) { 6 | username = username.toLowerCase(); 7 | 8 | const bestFriends = friendsList[constants.RELATIONSHIP_BEST_FRIEND] || []; 9 | const friends = friendsList[constants.RELATIONSHIP_FRIEND] || []; 10 | 11 | if (bestFriends.indexOf(username) >= 0) { 12 | return constants.RELATIONSHIP_BEST_FRIEND; 13 | } 14 | 15 | if (friends.indexOf(username) >= 0) { 16 | return constants.RELATIONSHIP_FRIEND; 17 | } 18 | } 19 | 20 | return constants.RELATIONSHIP_NONE; 21 | }; 22 | 23 | module.exports = { 24 | getRelationship 25 | }; 26 | -------------------------------------------------------------------------------- /server/main/ircConnectionState.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | var currentIrcConnectionState = {}; 4 | 5 | const storeConnectionState = function(name, value) { 6 | currentIrcConnectionState[name] = value; 7 | }; 8 | 9 | const deleteConnectionState = function(name) { 10 | delete currentIrcConnectionState[name]; 11 | }; 12 | 13 | const addToConnectionState = function(name, values) { 14 | let state = currentIrcConnectionState[name]; 15 | currentIrcConnectionState[name] = _.assign(state || {}, values); 16 | }; 17 | 18 | module.exports = { 19 | addToConnectionState, 20 | currentIrcConnectionState: () => currentIrcConnectionState, 21 | deleteConnectionState, 22 | storeConnectionState 23 | }; 24 | -------------------------------------------------------------------------------- /public/src/scss/logbrowser.scss: -------------------------------------------------------------------------------- 1 | .logbrowser { 2 | &__request { 3 | float: right; 4 | margin-left: 20px; 5 | line-height: 2em; 6 | } 7 | 8 | &__request input { 9 | vertical-align: middle; 10 | } 11 | 12 | &__items { 13 | list-style-type: none; 14 | margin: 0 0 0 -10px; 15 | padding: 0; 16 | line-height: 2em; 17 | } 18 | 19 | &__items li { 20 | display: inline-block; 21 | } 22 | 23 | &__items a { 24 | display: block; 25 | padding: 0 10px; 26 | } 27 | 28 | &__items a.current { 29 | background-color: #003; 30 | color: #fff; 31 | } 32 | 33 | @media #{$mobile-media} { 34 | &__request { 35 | float: none; 36 | margin-left: 0; 37 | margin-bottom: 5px; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/main/serverData.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | module.exports = function(io) { 4 | 5 | var serverData = {}; 6 | 7 | const clearServerData = function() { 8 | serverData = {}; 9 | }; 10 | 11 | const getServerData = function(server) { 12 | return serverData[server]; 13 | }; 14 | 15 | const getAllServerData = function() { 16 | return serverData; 17 | }; 18 | 19 | const setServerData = function(server, data) { 20 | let current = serverData[server] || {}; 21 | serverData[server] = _.assign(current, data); 22 | 23 | if (io) { 24 | io.emitServerData(null, server); 25 | } 26 | }; 27 | 28 | return { 29 | clearServerData, 30 | getServerData, 31 | getAllServerData, 32 | setServerData 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /public/src/js/store.js: -------------------------------------------------------------------------------- 1 | /*eslint no-undef: 0*/ 2 | import { createStore, applyMiddleware } from "redux"; 3 | import createSagaMiddleware from "redux-saga"; 4 | 5 | import sagas from "./sagas"; 6 | import reducers from "./reducers/index"; 7 | 8 | var composeWithDevTools; 9 | 10 | if (__DEV__) { 11 | composeWithDevTools = require("redux-devtools-extension").composeWithDevTools; 12 | } 13 | 14 | var sagaMiddleware = createSagaMiddleware(); 15 | var store = createStore( 16 | reducers, 17 | composeWithDevTools 18 | ? composeWithDevTools( 19 | applyMiddleware(sagaMiddleware) 20 | ) 21 | : applyMiddleware(sagaMiddleware) 22 | ); 23 | 24 | if (sagas) { 25 | sagas.forEach((saga) => sagaMiddleware.run(saga)); 26 | } 27 | 28 | export default store; 29 | -------------------------------------------------------------------------------- /serverplugins/twitch/users.js: -------------------------------------------------------------------------------- 1 | const twitchApi = require("./twitchApi"); 2 | const util = require("./util"); 3 | 4 | const requestTwitchUserInfo = function(username, callback) { 5 | twitchApi.krakenGetRequest( 6 | "users", 7 | { login: username }, 8 | util.acceptRequest(function(error, data) { 9 | if (!error) { 10 | const user = data.users && data.users[0]; 11 | if (user) { 12 | callback(null, user); 13 | } 14 | else { 15 | callback(null, null); 16 | } 17 | } 18 | 19 | else { 20 | util.warn( 21 | "Error occurred trying to get user info: " + 22 | (error && error.message) 23 | ); 24 | callback(error); 25 | } 26 | }) 27 | ); 28 | }; 29 | 30 | module.exports = { 31 | requestTwitchUserInfo 32 | }; 33 | -------------------------------------------------------------------------------- /public/src/scss/multichat.scss: -------------------------------------------------------------------------------- 1 | .multichat { 2 | position: relative; 3 | height: 100%; 4 | 5 | &__inner { 6 | position: absolute; 7 | top: 0; 8 | right: 0; 9 | bottom: 0; 10 | left: 0; 11 | 12 | display: grid; 13 | grid-gap: 1px; 14 | background-color: #ccc; 15 | } 16 | 17 | &__item { 18 | background-color: #fff; 19 | overflow: hidden; 20 | 21 | &--focus { 22 | position: relative; 23 | 24 | &:after { 25 | content: ""; 26 | position: absolute; 27 | top: 0; 28 | right: 0; 29 | bottom: 0; 30 | left: 0; 31 | 32 | border: 3px solid rgba(0,0,0,.1); 33 | pointer-events: none; 34 | z-index: 8; 35 | } 36 | } 37 | } 38 | 39 | .nochatview { 40 | h1 { 41 | font-size: 24px; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /public/src/scss/loader.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | position: relative; 3 | width: 80px; 4 | height: 80px; 5 | pointer-events: none; 6 | } 7 | 8 | .loader:before, .loader:after { 9 | content: ""; 10 | position: absolute; 11 | top: 50%; 12 | left: 50%; 13 | display: block; 14 | width: 0; 15 | height: 0; 16 | border-left: 40px solid transparent; 17 | border-right: 40px solid transparent; 18 | border-bottom: 69px solid #006; 19 | transform: translate3d(-40px, -34.5px, 0); 20 | transform-origin: 50% 64% 0; 21 | opacity: 0; 22 | animation: 1s infinite linear loader; 23 | } 24 | 25 | .loader:after { 26 | animation-delay: -.5s; 27 | } 28 | 29 | @keyframes loader { 30 | 0% { 31 | transform: translate3d(-40px, -34.5px, 0) scale(0.025); 32 | opacity: 1; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/src/scss/connectioninfo.scss: -------------------------------------------------------------------------------- 1 | .connection-warning { 2 | /*position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | z-index: 20;*/ 7 | 8 | background-color: #900; 9 | color: #fff; 10 | font-weight: bold; 11 | text-align: center; 12 | margin: 0; 13 | padding: 10px 0; 14 | line-height: 20px; 15 | list-style-type: none; 16 | white-space: nowrap; 17 | 18 | a { 19 | color: inherit !important; 20 | text-decoration: underline; 21 | } 22 | 23 | li { 24 | display: inline; 25 | margin: 0 .5em; 26 | } 27 | 28 | &__full { 29 | display: inline; 30 | } 31 | 32 | &__short { 33 | display: none; 34 | } 35 | 36 | @media #{$mobile-media} { 37 | &__full { 38 | display: none; 39 | } 40 | 41 | &__short { 42 | display: inline; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/util/files.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const sanitize = require("sanitize-filename"); 3 | 4 | const copyFile = function(source, target, cb) { 5 | var cbCalled = false; 6 | 7 | var rd = fs.createReadStream(source); 8 | rd.on("error", function(err) { 9 | done(err); 10 | }); 11 | var wr = fs.createWriteStream(target); 12 | wr.on("error", function(err) { 13 | done(err); 14 | }); 15 | wr.on("close", function() { 16 | done(); 17 | }); 18 | rd.pipe(wr); 19 | 20 | function done(err) { 21 | if (!cbCalled) { 22 | cb(err); 23 | cbCalled = true; 24 | } 25 | } 26 | }; 27 | 28 | const sanitizeFilename = function(str, replacement = "_") { 29 | return sanitize(str, { replacement }); 30 | }; 31 | 32 | module.exports = { 33 | copyFile, 34 | sanitizeFilename 35 | }; 36 | -------------------------------------------------------------------------------- /server/main/unseenHighlights.js: -------------------------------------------------------------------------------- 1 | module.exports = function(io) { 2 | var unseenHighlightIds = new Set(); 3 | 4 | // See an unseen highlight 5 | 6 | const reportHighlightAsSeen = function(messageId) { 7 | if (messageId) { 8 | unseenHighlightIds.delete(messageId); 9 | 10 | if (io) { 11 | io.emitUnseenHighlights(); 12 | } 13 | } 14 | }; 15 | 16 | const addUnseenHighlightId = function(highlightId) { 17 | unseenHighlightIds.add(highlightId); 18 | }; 19 | 20 | const clearUnseenHighlights = function() { 21 | unseenHighlightIds.clear(); 22 | 23 | if (io) { 24 | io.emitUnseenHighlights(); 25 | } 26 | }; 27 | 28 | return { 29 | addUnseenHighlightId, 30 | clearUnseenHighlights, 31 | reportHighlightAsSeen, 32 | unseenHighlightIds: () => unseenHighlightIds 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /serverplugins/twitch/badges.js: -------------------------------------------------------------------------------- 1 | const parseBadgeCode = function(badgeCode) { 2 | let data = badgeCode.split("/"); 3 | return { badge: data[0], version: data[1] }; 4 | }; 5 | 6 | const parseBadgeCodes = function(badgeCodesString) { 7 | if (!badgeCodesString) { 8 | return []; 9 | } 10 | 11 | let badgeCodes = badgeCodesString.split(","); 12 | return badgeCodes.map(parseBadgeCode); 13 | }; 14 | 15 | const parseBadgesInTags = function(tags) { 16 | if (!tags) { 17 | return; 18 | } 19 | 20 | if (tags.badges) { 21 | if (typeof tags.badges === "string") { 22 | tags.badges = parseBadgeCodes(tags.badges); 23 | } 24 | } 25 | else if ("badges" in tags) { 26 | // Type normalization 27 | tags.badges = []; 28 | } 29 | }; 30 | 31 | module.exports = { 32 | parseBadgeCodes, 33 | parseBadgesInTags 34 | }; 35 | -------------------------------------------------------------------------------- /public/src/js/chatview/NoChatView.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ChatWindowMenu from "./ChatWindowMenu.jsx"; 5 | 6 | class NoChatView extends PureComponent { 7 | render() { 8 | const { index, notFound } = this.props; 9 | var content; 10 | 11 | if (notFound) { 12 | content = [ 13 |

Huh…

, 14 |

Couldn’t find that channel or user :(

15 | ]; 16 | } 17 | 18 | else { 19 | content =

Open a chat :)

; 20 | } 21 | 22 | return ( 23 |
24 | { content } 25 | 26 |
27 | ); 28 | } 29 | } 30 | 31 | NoChatView.propTypes = { 32 | index: PropTypes.number, 33 | notFound: PropTypes.bool 34 | }; 35 | 36 | export default NoChatView; 37 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-05-11.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | ALTER TABLE "ircChannels" RENAME TO 'ircChannels_ME_TMP'; 3 | CREATE TABLE "ircChannels" ( 4 | "channelId" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 5 | "serverId" INTEGER NOT NULL REFERENCES "ircServers"("serverId"), 6 | "name" TEXT NOT NULL, 7 | "displayName" TEXT, 8 | "lastSeenTime" TEXT, 9 | "lastSeenUsername" TEXT, 10 | "lastSeenDisplayName" TEXT, 11 | "isEnabled" INTEGER NOT NULL DEFAULT 1 12 | ); 13 | INSERT INTO "ircChannels" ("channelId", "serverId", "name", "lastSeenTime", "lastSeenUsername", "lastSeenDisplayName", "isEnabled") SELECT "channelId", "serverId", "name", "lastSeenTime", "lastSeenUsername", "lastSeenDisplayName", "isEnabled" FROM "ircChannels_ME_TMP"; 14 | DROP TABLE "ircChannels_ME_TMP"; 15 | CREATE UNIQUE INDEX "serverChannel" ON "ircChannels" ("serverId", "name"); 16 | COMMIT; 17 | -------------------------------------------------------------------------------- /public/img/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 9 | 11 | 13 | 14 | -------------------------------------------------------------------------------- /public/src/js/reducers/offlineMessages.js: -------------------------------------------------------------------------------- 1 | import omit from "lodash/omit"; 2 | 3 | import * as actionTypes from "../actionTypes"; 4 | 5 | const offlineMessagesInitialState = {}; 6 | 7 | export default function (state = offlineMessagesInitialState, action) { 8 | 9 | switch (action.type) { 10 | case actionTypes.offlineMessages.ADD: { 11 | let { channel, message, messageToken } = action; 12 | let current = state[channel] || {}; 13 | return { 14 | ...state, 15 | [channel]: { 16 | ...current, 17 | [messageToken]: message 18 | } 19 | }; 20 | } 21 | 22 | case actionTypes.offlineMessages.REMOVE: { 23 | let { channel, messageToken } = action; 24 | if (state[channel] && state[channel][messageToken]) { 25 | return { 26 | ...state, 27 | [channel]: omit(state[channel], messageToken) 28 | }; 29 | } 30 | 31 | return state; 32 | } 33 | } 34 | 35 | return state; 36 | } 37 | -------------------------------------------------------------------------------- /pyramid.js: -------------------------------------------------------------------------------- 1 | // PYRAMID 2 | 3 | // DEBUG TEMP START 4 | 5 | const optional = require("optional"); 6 | const heapdump = optional("heapdump"); 7 | 8 | if (heapdump) { 9 | console.log( 10 | "Heap dumps enabled! If this process is acting up, " + 11 | `run:\n\tkill -USR2 ${process.pid}\n` + 12 | "And then send the resulting heapdump file to the developer.\n" + 13 | "This helps us improve the app.\n\n" + 14 | "Thanks for trying out Pyramid!\n" 15 | ); 16 | } 17 | 18 | // DEBUG TEMP END 19 | 20 | // Main app service 21 | const main = require("./server/main"); 22 | 23 | // Feed app into DB service 24 | require("./server/db")(main); 25 | 26 | // Feed app into plugin service 27 | require("./server/plugins")(main); 28 | 29 | // Feed app into IRC service 30 | require("./server/irc")(main); 31 | 32 | // IO service 33 | const io = require("./server/io")(main); 34 | 35 | // Start web service 36 | require("./server/web")(main, io); 37 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-06-24.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | ALTER TABLE "ircChannels" RENAME TO 'ircChannels_ME_TMP'; 3 | CREATE TABLE "ircChannels" ( 4 | "channelId" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 5 | "serverId" INTEGER NOT NULL REFERENCES "ircServers"("serverId"), 6 | "channelType" INTEGER DEFAULT 0 NOT NULL, 7 | "name" TEXT NOT NULL, 8 | "displayName" TEXT, 9 | "lastSeenTime" TEXT, 10 | "lastSeenUsername" TEXT, 11 | "lastSeenDisplayName" TEXT, 12 | "isEnabled" INTEGER NOT NULL DEFAULT 1 13 | ); 14 | INSERT INTO "ircChannels" ("channelId", "serverId", "name", "displayName", "lastSeenTime", "lastSeenUsername", "lastSeenDisplayName", "isEnabled") SELECT "channelId", "serverId", "name", "displayName", "lastSeenTime", "lastSeenUsername", "lastSeenDisplayName", "isEnabled" FROM "ircChannels_ME_TMP"; 15 | DROP TABLE "ircChannels_ME_TMP"; 16 | CREATE UNIQUE INDEX "serverChannel" ON "ircChannels" ("serverId", "name"); 17 | COMMIT; 18 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatHighlightsControls.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import { clearUnseenHighlights } from "../lib/io"; 6 | 7 | const onClick = function() { clearUnseenHighlights(); }; 8 | 9 | const ChatHighlightsControls = function(props) { 10 | const { unseenHighlights } = props; 11 | 12 | if (unseenHighlights && unseenHighlights.length) { 13 | return ( 14 | 22 | ); 23 | } 24 | 25 | return null; 26 | }; 27 | 28 | ChatHighlightsControls.propTypes = { 29 | unseenHighlights: PropTypes.array 30 | }; 31 | 32 | export default connect(({ 33 | unseenHighlights 34 | }) => ({ 35 | unseenHighlights 36 | }))(ChatHighlightsControls); 37 | -------------------------------------------------------------------------------- /scripts/bumpBuildNumber.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | 4 | const BUILD_NUMBER_FILE_NAME = path.join( 5 | __dirname, "..", "public", "src", "js", "buildNumber.js" 6 | ); 7 | 8 | const ENCODING = { encoding: "utf8" }; 9 | 10 | const contents = fs.readFileSync(BUILD_NUMBER_FILE_NAME, ENCODING); 11 | 12 | if (!contents) { 13 | throw new Error("No build number file found"); 14 | } 15 | 16 | const numberMatch = contents.match(/[0-9]+/); 17 | 18 | if (!numberMatch || !numberMatch[0]) { 19 | throw new Error("No build number found"); 20 | } 21 | 22 | const number = parseInt(numberMatch[0]); 23 | 24 | if (number <= 0 || isNaN(number)) { 25 | throw new Error("Invalid build number found"); 26 | } 27 | 28 | const newNumber = number + 1; 29 | 30 | fs.writeFileSync( 31 | BUILD_NUMBER_FILE_NAME, 32 | `export default ${newNumber};\n`, 33 | ENCODING 34 | ); 35 | 36 | console.log(`Bumped build number to ${newNumber}`); 37 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-06-15.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | ALTER TABLE "lines" RENAME TO 'lines_ME_TMP'; 3 | CREATE TABLE "lines" ( 4 | "lineId" TEXT PRIMARY KEY, 5 | "channelId" INTEGER NOT NULL REFERENCES "ircChannels"("channelId"), 6 | "type" TEXT NOT NULL, 7 | "time" TEXT NOT NULL, 8 | "date" TEXT NOT NULL, 9 | "username" TEXT, 10 | "message" TEXT, 11 | "symbol" TEXT, 12 | "tags" TEXT, 13 | "eventData" TEXT, 14 | "isHighlight" INTEGER 15 | ); 16 | INSERT INTO "lines" ("lineId", "channelId", "type", "time", "date", "username", "message", "symbol", "tags", "eventData") SELECT "lineId", "channelId", "type", "time", "date", "username", "message", "symbol", "tags", "eventData" FROM "lines_ME_TMP"; 17 | DROP TABLE "lines_ME_TMP"; 18 | CREATE INDEX "usernameDateTime" ON "lines" ("time", "date", "username"); 19 | CREATE INDEX "channelDateTime" ON "lines" ("channelId", "time", "date"); 20 | CREATE INDEX "isHighlight" ON "lines" ("isHighlight"); 21 | COMMIT; 22 | -------------------------------------------------------------------------------- /public/src/scss/switcher.scss: -------------------------------------------------------------------------------- 1 | .switcher, .switcher > ul, .controls { 2 | list-style-type: none; 3 | margin: 0; 4 | padding: 0; 5 | overflow: hidden; 6 | line-height: 20px; 7 | } 8 | 9 | .controls { 10 | user-select: none; 11 | 12 | li { 13 | display: inline-block; 14 | margin-left: 12px; 15 | } 16 | 17 | a em { 18 | font-style: normal; 19 | font-weight: normal; 20 | } 21 | } 22 | 23 | .switcher { 24 | user-select: none; 25 | 26 | li { 27 | display: inline-block; 28 | } 29 | 30 | button, a { 31 | color: inherit; 32 | font-family: inherit; 33 | font-weight: inherit; 34 | font-size: 100%; 35 | line-height: 20px; 36 | background-color: transparent; 37 | border: none; 38 | outline: none; 39 | margin: 0; 40 | padding: 0 6px; 41 | cursor: pointer; 42 | -webkit-appearance: none; 43 | } 44 | 45 | button:hover, a:hover { 46 | background-color: rgba(0, 0, 51, 0.067); 47 | text-decoration: none; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/src/js/sagas/channelCaches.js: -------------------------------------------------------------------------------- 1 | import { take } from "redux-saga/effects"; 2 | 3 | import * as actionTypes from "../actionTypes"; 4 | import actions from "../actions"; 5 | import store from "../store"; 6 | 7 | export default function* () { 8 | while (true) { 9 | let action = yield take([ 10 | actionTypes.channelCaches.APPEND, 11 | actionTypes.channelCaches.UPDATE 12 | ]); 13 | 14 | let channel = action.channel || action.data && action.data.channel; 15 | 16 | // Handle clears 17 | if (channel) { 18 | let items = action.cache || [action.data]; 19 | 20 | if (items && items.length) { 21 | items.forEach((item) => { 22 | if ( 23 | item && 24 | item.type === "clearchat" && 25 | item.time && 26 | item.username 27 | ) { 28 | let { time, username } = item; 29 | store.dispatch(actions.channelCaches.clearUser( 30 | channel, username, time 31 | )); 32 | } 33 | }); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/src/js/lib/timeZones.js: -------------------------------------------------------------------------------- 1 | import timeZoneData from "./timeZoneData"; 2 | 3 | export function timeZoneList() { 4 | const rawList = timeZoneData; 5 | 6 | // Put the "other" category at the bottom 7 | const other = rawList.filter( 8 | (tz) => (tz.indexOf("/") < 0 || tz.substr(0, 4) === "Etc/") && tz !== "UTC" 9 | ); 10 | const nonOther = rawList.filter( 11 | (tz) => tz.indexOf("/") >= 0 && tz.substr(0, 4) !== "Etc/" 12 | ); 13 | 14 | // UTC at the top 15 | return ["UTC"].concat(nonOther.concat(other)); 16 | } 17 | 18 | function beautifyTimeZoneName(tz) { 19 | return tz 20 | 21 | // Convert underscores to spaces 22 | .replace(/_/g, " ") 23 | 24 | // Convert subdir slashes to prefix separators 25 | .replace(/\//g, ": ") 26 | 27 | // Find implied spaces (lowercase char followed by uppercase char) 28 | .replace(/([a-z])([A-Z])/g, "$1 $2"); 29 | } 30 | 31 | export function timeZoneFormattedList() { 32 | const list = timeZoneList(); 33 | return list.map((tz) => [ tz, beautifyTimeZoneName(tz) ]); 34 | } 35 | -------------------------------------------------------------------------------- /server/util/time.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | // TODO: Handle time zone? 3 | 4 | const hms = function(d) { 5 | if (!d) { d = new Date(); } 6 | return moment(d).format("HH:mm:ss"); 7 | }; 8 | 9 | const ymd = function(d) { 10 | if (!d) { d = new Date(); } 11 | return moment(d).format("YYYY-MM-DD"); 12 | }; 13 | 14 | const ym = function(d) { 15 | if (!d) { d = new Date(); } 16 | return moment(d).format("YYYY-MM"); 17 | }; 18 | 19 | const hmsPrefix = function(str, d) { 20 | return "[" + hms(d) + "] " + str; 21 | }; 22 | 23 | const ymdhmsPrefix = function(str, d) { 24 | return "[" + ymd(d) + " " + hms(d) + "] " + str; 25 | }; 26 | 27 | const offsetDate = function(date, days) { 28 | return new Date( 29 | date.getFullYear(), 30 | date.getMonth(), 31 | date.getDate() + days, 32 | date.getHours(), 33 | date.getMinutes(), 34 | date.getSeconds(), 35 | date.getMilliseconds() 36 | ); 37 | }; 38 | 39 | module.exports = { 40 | hms, 41 | hmsPrefix, 42 | offsetDate, 43 | ym, 44 | ymd, 45 | ymdhmsPrefix 46 | }; 47 | -------------------------------------------------------------------------------- /public/img/cog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-07-10.sql: -------------------------------------------------------------------------------- 1 | -- Adds channelConfig column to ircChannels table 2 | BEGIN TRANSACTION; 3 | ALTER TABLE "ircChannels" RENAME TO 'ircChannels_ME_TMP'; 4 | CREATE TABLE "ircChannels" ( 5 | "channelId" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 6 | "serverId" INTEGER NOT NULL REFERENCES "ircServers"("serverId"), 7 | "channelType" INTEGER DEFAULT 0 NOT NULL, 8 | "name" TEXT NOT NULL, 9 | "displayName" TEXT, 10 | "lastSeenTime" TEXT, 11 | "lastSeenUsername" TEXT, 12 | "lastSeenDisplayName" TEXT, 13 | "isEnabled" INTEGER NOT NULL DEFAULT 1, 14 | "channelConfig" TEXT 15 | ); 16 | INSERT INTO "ircChannels" ("channelId", "serverId", "channelType", "name", "displayName", "lastSeenTime", "lastSeenUsername", "lastSeenDisplayName", "isEnabled") SELECT "channelId", "serverId", "channelType", "name", "displayName", "lastSeenTime", "lastSeenUsername", "lastSeenDisplayName", "isEnabled" FROM "ircChannels_ME_TMP"; 17 | DROP TABLE "ircChannels_ME_TMP"; 18 | CREATE UNIQUE INDEX "serverChannel" ON "ircChannels" ("serverId", "channelType", "name"); 19 | COMMIT; 20 | -------------------------------------------------------------------------------- /serverplugins/twitch/groupChats.js: -------------------------------------------------------------------------------- 1 | const stringUtils = require("../../server/util/strings"); 2 | const twitchApi = require("./twitchApi"); 3 | const util = require("./util"); 4 | 5 | const requestGroupChatInfo = function(oauthToken, callback) { 6 | twitchApi.chatdepotGetRequest( 7 | "room_memberships", 8 | oauthToken, 9 | {}, 10 | util.acceptRequest(function(error, data) { 11 | if (!error) { 12 | const memberships = data.memberships; 13 | const groupChats = []; 14 | memberships.forEach((membership) => { 15 | let { room } = membership; 16 | if (membership.is_confirmed) { 17 | groupChats.push({ 18 | name: room.irc_channel, 19 | displayName: stringUtils.clean(room.display_name) 20 | }); 21 | } 22 | }); 23 | 24 | callback(null, groupChats); 25 | } 26 | else { 27 | util.warn( 28 | "Error occurred trying to get group chat info: " + 29 | (error && error.message) 30 | ); 31 | callback(error); 32 | } 33 | }) 34 | ); 35 | }; 36 | 37 | module.exports = { 38 | requestGroupChatInfo 39 | }; 40 | -------------------------------------------------------------------------------- /public/src/js/components/AccessKeys.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import { setAppConfigValue } from "../lib/io"; 6 | import { setDarkModeStatus } from "../lib/visualBehavior"; 7 | 8 | class AccessKeys extends PureComponent { 9 | constructor(props) { 10 | super(props); 11 | 12 | this.toggleDarkMode = this.toggleDarkMode.bind(this); 13 | } 14 | 15 | toggleDarkMode() { 16 | let { enableDarkMode = false } = this.props; 17 | 18 | setDarkModeStatus(!enableDarkMode); 19 | setAppConfigValue("enableDarkMode", !enableDarkMode); 20 | } 21 | 22 | render() { 23 | return ( 24 |
25 | Dark mode 30 |
31 | ); 32 | } 33 | } 34 | 35 | AccessKeys.propTypes = { 36 | enableDarkMode: PropTypes.bool 37 | }; 38 | 39 | export default connect(({ 40 | appConfig: { enableDarkMode } 41 | }) => ({ 42 | enableDarkMode 43 | }))(AccessKeys); 44 | -------------------------------------------------------------------------------- /public/src/scss/menu.scss: -------------------------------------------------------------------------------- 1 | .menu { 2 | list-style-type: none; 3 | margin: 0; 4 | padding: 10px 0; 5 | user-select: none; 6 | 7 | .sep { 8 | border-top: 1px solid #ccc; 9 | } 10 | 11 | &__link { 12 | display: block; 13 | padding: 5px 10px; 14 | line-height: 17px; 15 | color: #556; 16 | font-weight: normal; 17 | 18 | &:hover { 19 | text-decoration: none; 20 | } 21 | } 22 | } 23 | 24 | .pop-menu { 25 | display: none; 26 | position: absolute; 27 | top: 40px; 28 | right: 0; 29 | z-index: 30; 30 | min-width: 130px; 31 | text-align: right; 32 | border: 1px solid #ccc; 33 | background-color: #fff; 34 | padding: 0; 35 | box-shadow: 0 5px 12px 0 rgba(0,0,0,.3); 36 | 37 | .menu__link:hover { 38 | background-color: #eee; 39 | text-decoration: none; 40 | } 41 | } 42 | 43 | .menu-opener { 44 | position: absolute; 45 | top: 0; 46 | right: 0; 47 | 48 | &, a { 49 | display: block; 50 | width: 36px; 51 | height: 40px; 52 | } 53 | 54 | &--active { 55 | background-color: #e7e7e7; 56 | } 57 | 58 | img { 59 | position: absolute; 60 | top: 12px; 61 | right: 10px; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatSystemLogControls.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import TimeAgo from "react-timeago"; 5 | 6 | const suffixlessFormatter = function(value, unit, suffix, date, defaultFormatter) { 7 | return defaultFormatter(value, unit, suffix, date).replace(/\s+ago/, ""); 8 | }; 9 | 10 | const ChatSystemLogControls = function(props) { 11 | let { awakeTime: awakeTimeString } = props; 12 | 13 | if (awakeTimeString) { 14 | let awakeTime = new Date(awakeTimeString); 15 | let fullString = awakeTime.toString(); 16 | 17 | return ( 18 | 26 | ); 27 | } 28 | 29 | return null; 30 | }; 31 | 32 | ChatSystemLogControls.propTypes = { 33 | awakeTime: PropTypes.string 34 | }; 35 | 36 | export default connect(({ 37 | systemInfo: { awakeTime } 38 | }) => ({ 39 | awakeTime 40 | }))(ChatSystemLogControls); 41 | -------------------------------------------------------------------------------- /public/src/js/lib/scrolling.js: -------------------------------------------------------------------------------- 1 | export function scrollToTheTop(container) { 2 | container.scrollTop = 0; 3 | } 4 | 5 | export function areWeScrolledToTheBottom(container, content) { 6 | if (!content) { 7 | content = container.children[0]; 8 | } 9 | 10 | let contentHeight = content && content.clientHeight || 0; 11 | let containerHeight = container.clientHeight; 12 | let scrollTop = container.scrollTop || 0; 13 | 14 | return (contentHeight - (scrollTop + containerHeight)) <= 100 || 15 | containerHeight >= contentHeight; 16 | } 17 | 18 | export function scrollToTheBottom(container, content) { 19 | if (!content) { 20 | content = container.children[0]; 21 | } 22 | 23 | container.scrollTop = content && content.clientHeight || 0; 24 | } 25 | 26 | export function stickToTheBottom(container) { 27 | if (areWeScrolledToTheBottom(container)) { 28 | scrollToTheBottom(container); 29 | } 30 | else { 31 | // TODO: If you're *not* scrolled to the bottom, scroll UP 32 | // by a specific amount, so it looks like the content is 33 | // not moving 34 | 35 | // Plus, add a notice that there's new content? 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/src/js/reducers/categoryCaches.js: -------------------------------------------------------------------------------- 1 | import clone from "lodash/clone"; 2 | 3 | import * as actionTypes from "../actionTypes"; 4 | import { cacheMessageItem } from "../lib/messageCaches"; 5 | 6 | const categoryCachesInitialState = {}; 7 | 8 | export default function (state = categoryCachesInitialState, action) { 9 | 10 | switch (action.type) { 11 | case actionTypes.categoryCaches.UPDATE: { 12 | let { categoryName, cache } = action; 13 | let item = state[categoryName] || {}; 14 | return { 15 | ...state, 16 | [categoryName]: { 17 | ...item, 18 | cache, 19 | lastReload: new Date() 20 | } 21 | }; 22 | } 23 | 24 | case actionTypes.categoryCaches.APPEND: { 25 | let s = clone(state), d = action.data; 26 | 27 | if (!s[d.categoryName]) { 28 | s[d.categoryName] = { cache: [] }; 29 | } 30 | 31 | // Simpler append logic than channelCaches, 32 | // due to this stream only including message events 33 | 34 | s[d.categoryName].cache = cacheMessageItem( 35 | s[d.categoryName].cache, d.item 36 | ); 37 | 38 | return s; 39 | } 40 | } 41 | 42 | return state; 43 | } 44 | -------------------------------------------------------------------------------- /public/src/js/reducers/logFiles.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from "../actionTypes"; 2 | import omit from "lodash/omit"; 3 | 4 | const logFilesInitialState = {}; 5 | 6 | export default function (state = logFilesInitialState, action) { 7 | 8 | switch (action.type) { 9 | case actionTypes.logFiles.UPDATE: { 10 | let currentSubjectValue = state[action.subjectName] || {}; 11 | return { 12 | ...state, 13 | [action.subjectName]: { 14 | ...currentSubjectValue, 15 | [action.date]: action.lines 16 | } 17 | }; 18 | } 19 | case actionTypes.logFiles.CLEAR: 20 | if (action.channel && action.date) { 21 | // Clear specific file: Channel and date 22 | if (state[action.channel]) { 23 | return { 24 | ...state, 25 | [action.channel]: omit(state[action.channel], action.date) 26 | }; 27 | } 28 | } 29 | else if (action.channel) { 30 | // Clear all channel logs 31 | return { 32 | ...state, 33 | [action.channel]: {} 34 | }; 35 | } 36 | else { 37 | // Clear everything 38 | return {}; 39 | } 40 | return state; 41 | } 42 | 43 | return state; 44 | } 45 | -------------------------------------------------------------------------------- /public/src/js/components/VersionNumber.jsx: -------------------------------------------------------------------------------- 1 | /*eslint no-undef: 0*/ 2 | import React from "react"; 3 | import PropTypes from "prop-types"; 4 | 5 | import buildNumber from "../buildNumber"; 6 | import { VERSION } from "../constants"; 7 | 8 | const block = "version"; 9 | 10 | const VERSION_NAMES = { 11 | 1: "beta 1", 12 | 46: "beta 2", 13 | 74: "beta 3", 14 | 97: "beta 4" 15 | }; 16 | 17 | const VersionNumber = function(props) { 18 | let { verbose } = props; 19 | let hasName = !!VERSION_NAMES[buildNumber]; 20 | 21 | var buildText; 22 | 23 | if (hasName) { 24 | buildText = VERSION_NAMES[buildNumber] + 25 | (verbose ? ` (build ${buildNumber})` : ""); 26 | } 27 | else { 28 | buildText = `build ${buildNumber}`; 29 | } 30 | 31 | var text = verbose ? `${VERSION} ${buildText}` : buildText; 32 | 33 | if (__DEV__) { 34 | text += verbose ? " (dev)" : " dev"; 35 | } 36 | 37 | let className = block + 38 | (hasName ? ` ${block}--named` : ""); 39 | 40 | return { text }; 41 | }; 42 | 43 | VersionNumber.propTypes = { 44 | verbose: PropTypes.bool 45 | }; 46 | 47 | export default VersionNumber; 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Andy Graulund 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /public/src/js/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { withRouter } from "react-router-dom"; 4 | 5 | import AccessKeys from "./AccessKeys.jsx"; 6 | import ConnectionInfo from "./ConnectionInfo.jsx"; 7 | import Header from "./Header.jsx"; 8 | import Sidebar from "./Sidebar.jsx"; 9 | 10 | const block = "app-container"; 11 | 12 | class App extends PureComponent { 13 | render() { 14 | const { children } = this.props; 15 | 16 | return ( 17 |
18 | 19 |
20 | 21 |
22 |
23 |
24 | 25 |
26 | { children } 27 |
28 |
29 |
30 |
31 |
32 | ); 33 | } 34 | } 35 | 36 | App.propTypes = { 37 | children: PropTypes.node 38 | }; 39 | 40 | export default withRouter(App); 41 | -------------------------------------------------------------------------------- /views/layout.ejs: -------------------------------------------------------------------------------- 1 | <% 2 | var bodyClasses = [], _enableScripts = true; 3 | 4 | try { 5 | if (appConfig && appConfig.enableDarkMode) { 6 | bodyClasses.push("darkmode"); 7 | } 8 | } catch(e) {} 9 | 10 | try { 11 | _enableScripts = enableScripts; 12 | } catch(e) {} 13 | 14 | var bodyClassName = bodyClasses.join(" "); 15 | %> 16 | 17 | 18 | 19 | Pyramid 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | <%- body %> 30 | <% if (_enableScripts) { %> 31 | 32 | 33 | <% } %> 34 | 35 | 36 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/LogLine.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const block = "logline"; 5 | const prefixClassName = `${block}__prefix`; 6 | 7 | class LogLine extends PureComponent { 8 | render() { 9 | const { level, message, server } = this.props; 10 | 11 | const className = block + 12 | (level ? ` ${block}--${level}` : ""); 13 | 14 | var content = message; 15 | 16 | var prefix; 17 | 18 | if (server) { 19 | prefix = ( 20 | 21 | { server } 22 | 23 | ); 24 | } 25 | else { 26 | prefix = ( 27 | 28 | System 29 | 30 | ); 31 | } 32 | 33 | return ( 34 | 35 | { prefix } 36 | { " " } 37 | { content } 38 | 39 | ); 40 | } 41 | } 42 | 43 | LogLine.propTypes = { 44 | level: PropTypes.string, 45 | lineId: PropTypes.string, 46 | message: PropTypes.string.isRequired, 47 | server: PropTypes.string, 48 | time: PropTypes.string, 49 | type: PropTypes.string 50 | }; 51 | 52 | export default LogLine; 53 | -------------------------------------------------------------------------------- /server/util/tokens.js: -------------------------------------------------------------------------------- 1 | const sodium = require("sodium").api; 2 | 3 | const SESSION_KEY_LENGTH = 80; 4 | const CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 5 | 6 | var acceptedTokens = []; 7 | 8 | const rand = function() { 9 | return sodium.randombytes_random() / 0xffffffff; 10 | }; 11 | 12 | const addToAcceptedTokens = function(token) { 13 | if (token) { 14 | acceptedTokens = [ ...acceptedTokens, token ]; 15 | } 16 | }; 17 | 18 | const isAnAcceptedToken = function(token) { 19 | return acceptedTokens.indexOf(token) >= 0; 20 | }; 21 | 22 | const clearAcceptedTokens = function() { 23 | acceptedTokens = []; 24 | }; 25 | 26 | const generateToken = function(length = SESSION_KEY_LENGTH) { 27 | var out = ""; 28 | 29 | for (var i = 0; i < length; i++) { 30 | out += CHARS[Math.round(rand() * (CHARS.length - 1))]; 31 | } 32 | 33 | return out; 34 | }; 35 | 36 | const generateAcceptedToken = function(length = SESSION_KEY_LENGTH) { 37 | const token = generateToken(length); 38 | addToAcceptedTokens(token); 39 | return token; 40 | }; 41 | 42 | module.exports = { 43 | addToAcceptedTokens, 44 | clearAcceptedTokens, 45 | generateAcceptedToken, 46 | generateToken, 47 | isAnAcceptedToken 48 | }; 49 | -------------------------------------------------------------------------------- /public/src/js/components/ChannelLink.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { PAGE_TYPES } from "../constants"; 5 | import ChannelName from "./ChannelName.jsx"; 6 | import ChatViewLink from "./ChatViewLink.jsx"; 7 | 8 | class ChannelLink extends PureComponent { 9 | render() { 10 | const { 11 | channel, 12 | displayName, 13 | displayServer, 14 | noConversationName, 15 | server, 16 | strong 17 | } = this.props; 18 | 19 | if (!channel) { 20 | return null; 21 | } 22 | 23 | return ( 24 | 28 | 37 | 38 | ); 39 | } 40 | } 41 | 42 | ChannelLink.propTypes = { 43 | channel: PropTypes.string.isRequired, 44 | displayName: PropTypes.string, 45 | displayServer: PropTypes.bool, 46 | noConversationName: PropTypes.bool, 47 | server: PropTypes.string, 48 | strong: PropTypes.bool 49 | }; 50 | 51 | export default ChannelLink; 52 | -------------------------------------------------------------------------------- /public/src/js/lib/connectionStatus.js: -------------------------------------------------------------------------------- 1 | import actions from "../actions"; 2 | import store from "../store"; 3 | import { serverNameFromChannelUri } from "./channelNames"; 4 | 5 | export const GLOBAL_CONNECTION = "_global"; 6 | 7 | export const STATUS = { 8 | ABORTED: "aborted", 9 | CONNECTED: "connected", 10 | DISCONNECTED: "disconnected", 11 | FAILED: "failed", 12 | REJECTED: "rejected" 13 | }; 14 | 15 | function setConnectionStatus(key, status) { 16 | const info = { status, time: new Date() }; 17 | store.dispatch(actions.connectionStatus.update({ [key]: info })); 18 | } 19 | 20 | export function setGlobalConnectionStatus(status) { 21 | setConnectionStatus(GLOBAL_CONNECTION, status); 22 | } 23 | 24 | export function setIrcConnectionStatus(serverName, status) { 25 | setConnectionStatus(serverName, status); 26 | } 27 | 28 | export function getMyIrcNick(serverName) { 29 | let state = store.getState(); 30 | 31 | if (serverName && state.connectionStatus[serverName]) { 32 | return state.connectionStatus[serverName].nick; 33 | } 34 | 35 | return ""; 36 | } 37 | 38 | export function getMyIrcNickFromChannel(channel) { 39 | let serverName = serverNameFromChannelUri(channel); 40 | 41 | if (serverName) { 42 | return getMyIrcNick(serverName); 43 | } 44 | 45 | return ""; 46 | } 47 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatConnectionEventLine.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { STATUS } from "../../lib/connectionStatus"; 5 | 6 | const block = "connectionevent"; 7 | 8 | class ChatConnectionEventLine extends PureComponent { 9 | render() { 10 | const { server, status } = this.props; 11 | 12 | var by = "by"; 13 | 14 | switch (status) { 15 | case STATUS.CONNECTED: 16 | by = "to"; 17 | break; 18 | case STATUS.DISCONNECTED: 19 | by = "from"; 20 | break; 21 | case STATUS.FAILED: 22 | by = "to connect to"; 23 | break; 24 | case STATUS.ABORTED: 25 | by = "connecting to"; 26 | break; 27 | } 28 | 29 | const className = block + 30 | (status !== STATUS.CONNECTED ? ` ${block}--bad` : ""); 31 | 32 | return ( 33 | 34 | 35 | { status } 36 | 37 | { " " + by + " " + server } 38 | 39 | ); 40 | } 41 | } 42 | 43 | ChatConnectionEventLine.propTypes = { 44 | channel: PropTypes.string, 45 | channelName: PropTypes.string, 46 | lineId: PropTypes.string, 47 | server: PropTypes.string.isRequired, 48 | status: PropTypes.string.isRequired, 49 | time: PropTypes.string, 50 | type: PropTypes.string 51 | }; 52 | 53 | export default ChatConnectionEventLine; 54 | -------------------------------------------------------------------------------- /public/src/scss/welcome.scss: -------------------------------------------------------------------------------- 1 | .welcome { 2 | max-width: 600px; 3 | margin: 30px auto 100px; 4 | padding: 0 20px; 5 | 6 | h1 { 7 | text-align: center; 8 | font-size: 300%; 9 | margin: 0 0 50px; 10 | } 11 | 12 | &__graphic { 13 | margin: 0 0 50px; 14 | text-align: center; 15 | 16 | svg { 17 | max-width: 385px; 18 | } 19 | } 20 | 21 | &__end, &__error { 22 | text-align: center; 23 | } 24 | 25 | &__error { 26 | background-color: #900; 27 | color: #fff; 28 | padding: 10px 20px; 29 | margin: 30px 0; 30 | } 31 | 32 | input[type="submit"] { 33 | font-size: 200%; 34 | font-weight: bold; 35 | } 36 | 37 | textarea { 38 | width: 100%; 39 | height: 200px; 40 | } 41 | 42 | .network-type-selector { 43 | border: 1px solid #ccc; 44 | margin: 1em 0 2em; 45 | 46 | &__head { 47 | position: relative; 48 | border-bottom: 1px solid #ccc; 49 | padding: 10px 20px; 50 | 51 | p, ul { 52 | display: inline; 53 | } 54 | } 55 | 56 | &__main { 57 | padding: 10px 20px; 58 | } 59 | 60 | &__switcher { 61 | a { 62 | border: 1px solid #ccc; 63 | border-radius: 3px; 64 | margin: 0 0 0 8px; 65 | padding: 4px 8px; 66 | } 67 | } 68 | } 69 | 70 | @media #{$mobile-media} { 71 | h1 { 72 | font-size: 200%; 73 | margin-bottom: 30px; 74 | } 75 | 76 | &__graphic { 77 | margin-bottom: 30px; 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatViewFooter.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ChatInput from "./ChatInput.jsx"; 5 | import ChatViewLogPagination from "./ChatViewLogPagination.jsx"; 6 | 7 | class ChatViewFooter extends PureComponent { 8 | render() { 9 | const { 10 | displayName, 11 | focus, 12 | index, 13 | isLiveChannel, 14 | logDate, 15 | logDetails, 16 | pageNumber, 17 | pageQuery, 18 | pageType 19 | } = this.props; 20 | 21 | if (isLiveChannel) { 22 | return ; 28 | } 29 | else if (logDate) { 30 | return ; 37 | } 38 | 39 | return null; 40 | } 41 | } 42 | 43 | ChatViewFooter.propTypes = { 44 | displayName: PropTypes.string, 45 | focus: PropTypes.bool, 46 | index: PropTypes.number, 47 | isLiveChannel: PropTypes.bool, 48 | logDate: PropTypes.string, 49 | logDetails: PropTypes.object, 50 | pageNumber: PropTypes.number, 51 | pageQuery: PropTypes.string.isRequired, 52 | pageType: PropTypes.string.isRequired 53 | }; 54 | 55 | export default ChatViewFooter; 56 | -------------------------------------------------------------------------------- /public/src/js/components/ChatViewLink.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { Link } from "react-router-dom"; 4 | 5 | import { locationIsMultiChat, setViewInCurrent } from "../lib/multiChat"; 6 | import { subjectUrl } from "../lib/routeHelpers"; 7 | 8 | // ChatViewLink: We're opening in the frame currently in focus if in multi chat view 9 | 10 | class ChatViewLink extends PureComponent { 11 | constructor(props) { 12 | super(props); 13 | this.onClick = this.onClick.bind(this); 14 | } 15 | 16 | onClick(evt) { 17 | let { date, pageNumber, query, type } = this.props; 18 | 19 | if ( 20 | locationIsMultiChat(location) && 21 | setViewInCurrent(type, query, date, pageNumber) 22 | ) { 23 | evt.preventDefault(); 24 | } 25 | } 26 | 27 | render() { 28 | let { 29 | children, 30 | date, 31 | pageNumber, 32 | query, 33 | type, 34 | ...props 35 | } = this.props; 36 | 37 | let url = subjectUrl(type, query, date, pageNumber); 38 | 39 | return ( 40 | 41 | { children } 42 | 43 | ); 44 | } 45 | } 46 | 47 | ChatViewLink.propTypes = { 48 | children: PropTypes.node.isRequired, 49 | date: PropTypes.string, 50 | pageNumber: PropTypes.number, 51 | query: PropTypes.string.isRequired, 52 | type: PropTypes.string.isRequired 53 | }; 54 | 55 | export default ChatViewLink; 56 | -------------------------------------------------------------------------------- /public/src/scss/basic.scss: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: San Francisco, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular', "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", Helvetica, sans-serif; 3 | font-size: 81.25%; 4 | line-height: 1.25rem; 5 | color: #222227; 6 | background-color: #fff; 7 | margin: 0; 8 | 9 | &.chrome-sf-font { 10 | letter-spacing: -.0125em; 11 | } 12 | } 13 | 14 | strong, dt { 15 | font-weight: bold; 16 | } 17 | 18 | em { 19 | font-style: italic; 20 | } 21 | 22 | a { 23 | color: #29d; 24 | font-weight: bold; 25 | text-decoration: none; 26 | 27 | &:hover { 28 | text-decoration: underline; 29 | } 30 | } 31 | 32 | h1 { 33 | font-size: 150%; 34 | margin: 10px 0; 35 | 36 | a { 37 | color: inherit; 38 | } 39 | } 40 | 41 | h2 { 42 | font-size: 125%; 43 | margin: 10px 0; 44 | } 45 | 46 | p.l { 47 | clear: left; 48 | 49 | label { 50 | float: left; 51 | width: 10em; 52 | } 53 | } 54 | 55 | p.ta { 56 | label { 57 | display: block; 58 | } 59 | 60 | textarea { 61 | width: 100%; 62 | height: 100px; 63 | box-sizing: border-box; 64 | } 65 | } 66 | 67 | .accesskeys { 68 | position: absolute; 69 | left: -9999px; 70 | opacity: 0; 71 | pointer-events: none; 72 | z-index: -1; 73 | } 74 | 75 | .userlink, .channelname { 76 | em { 77 | font-style: normal; 78 | font-weight: normal; 79 | } 80 | } 81 | 82 | .channelname { 83 | a { 84 | color: inherit !important; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /public/src/scss/mainview.scss: -------------------------------------------------------------------------------- 1 | .mainview { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100%; 6 | overflow: hidden; 7 | 8 | > * { 9 | flex: 0 0 auto; 10 | } 11 | 12 | &__content { 13 | display: flex; 14 | flex: 1; 15 | 16 | &__primary { 17 | flex: 1; 18 | } 19 | } 20 | 21 | &__frame-container { 22 | position: relative; 23 | } 24 | 25 | &__inner-frame { 26 | position: absolute; 27 | top: 0; 28 | right: 0; 29 | bottom: 0; 30 | left: 0; 31 | overflow: auto; 32 | } 33 | 34 | &__top { 35 | padding: 10px 20px; 36 | min-height: 20px; 37 | background-color: #fff; 38 | border-bottom: 1px solid #ccc; 39 | white-space: nowrap; 40 | 41 | .app-container--with-notice & { 42 | top: 40px; 43 | } 44 | 45 | &__main { 46 | display: flex; 47 | 48 | h2 { 49 | flex: 1 1 auto; 50 | margin: 0; 51 | line-height: 20px; 52 | overflow: hidden; 53 | } 54 | 55 | > div, > ul { 56 | flex: 0 0 auto; 57 | } 58 | } 59 | } 60 | 61 | &__loader { 62 | position: absolute; 63 | top: 0; 64 | left: 0; 65 | right: 0; 66 | bottom: 0; 67 | width: 100%; 68 | height: 100%; 69 | pointer-events: none; 70 | } 71 | 72 | @media #{$mobile-media} { 73 | margin: 0; 74 | 75 | &__top { 76 | left: 0; 77 | padding-left: 40px; 78 | padding-right: 10px; 79 | } 80 | 81 | &__loader { 82 | left: 0; 83 | width: 100%; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/util/strings.js: -------------------------------------------------------------------------------- 1 | // Sanitizing 2 | 3 | const normalise = function(s) { 4 | if (s && s.replace) { 5 | return s.replace(/\s+/g, " "); 6 | } 7 | 8 | return ""; 9 | }; 10 | 11 | const clean = function(s) { 12 | if (s && s.trim) { 13 | return normalise(s).trim(); 14 | } 15 | 16 | return ""; 17 | }; 18 | 19 | const oneWord = function(s) { 20 | const cleanedString = clean(s); 21 | 22 | // Only return the first word in the string, for strings where space is not allowed 23 | if (cleanedString) { 24 | return cleanedString.replace(/\s.*$/, ""); 25 | } 26 | 27 | return ""; 28 | }; 29 | 30 | const formatUriName = function(s) { 31 | const oneWordString = oneWord(s); 32 | 33 | // No slashes allowed, and all lowercase 34 | if (oneWordString) { 35 | return oneWordString.replace(/\//g, "").toLowerCase(); 36 | } 37 | 38 | return ""; 39 | }; 40 | 41 | const lowerClean = function(s) { 42 | const cleanedString = clean(s); 43 | 44 | // All lowercase 45 | if (cleanedString) { 46 | return cleanedString.toLowerCase(); 47 | } 48 | 49 | return ""; 50 | }; 51 | 52 | // Misc 53 | 54 | const pluralize = function(value, base, addition) { 55 | // Example: 8, "banana", "s", returns either "banana" or "bananas" 56 | 57 | value = parseInt(value, 10); 58 | 59 | if (value === 1) { 60 | return base; 61 | } 62 | 63 | return base + addition; 64 | }; 65 | 66 | module.exports = { 67 | clean, 68 | formatUriName, 69 | lowerClean, 70 | normalise, 71 | oneWord, 72 | pluralize 73 | }; 74 | -------------------------------------------------------------------------------- /server/main/unseenConversations.js: -------------------------------------------------------------------------------- 1 | const { getChannelUri } = require("../util/channels"); 2 | 3 | module.exports = function(io) { 4 | var unseenConversations = {}; 5 | 6 | // Unseen conversations 7 | // These are grouped by users. You can only report an entire user's 8 | // private messages as unseen at the same time, but a user can have a 9 | // number of unseen private messages that can increase 10 | 11 | const reportUserAsSeen = function(serverName, username) { 12 | if (serverName && username) { 13 | delete unseenConversations[getChannelUri(serverName, username)]; 14 | 15 | if (io) { 16 | io.emitUnseenConversations(); 17 | } 18 | } 19 | }; 20 | 21 | const addUnseenUser = function(serverName, username, userDisplayName, count = 1) { 22 | let uri = getChannelUri(serverName, username); 23 | let user = unseenConversations[uri]; 24 | 25 | if (user) { 26 | user.count += count; 27 | user.userDisplayName = userDisplayName; 28 | } 29 | 30 | else { 31 | user = { count, serverName, username, userDisplayName }; 32 | unseenConversations[uri] = user; 33 | } 34 | 35 | if (io) { 36 | io.emitUnseenConversations(); 37 | } 38 | }; 39 | 40 | const clearUnseenConversations = function() { 41 | unseenConversations = {}; 42 | 43 | if (io) { 44 | io.emitUnseenConversations(); 45 | } 46 | }; 47 | 48 | return { 49 | addUnseenUser, 50 | clearUnseenConversations, 51 | reportUserAsSeen, 52 | unseenConversations: () => unseenConversations 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /public/src/js/lib/ircConfigs.js: -------------------------------------------------------------------------------- 1 | import store from "../store"; 2 | import actions from "../actions"; 3 | import { getChannelUri, parseChannelUri } from "./channelNames"; 4 | 5 | export function calibrateMultiServerChannels(ircConfigs) { 6 | let multiServerChannels = []; 7 | let namesSeen = []; 8 | 9 | Object.keys(ircConfigs).forEach((name) => { 10 | let c = ircConfigs[name]; 11 | if (c && c.channels) { 12 | Object.keys(c.channels).forEach((ch) => { 13 | if (namesSeen.indexOf(ch) >= 0) { 14 | multiServerChannels.push(ch); 15 | } 16 | namesSeen.push(ch); 17 | }); 18 | } 19 | }); 20 | 21 | return multiServerChannels; 22 | } 23 | 24 | export function resetMultiServerChannels() { 25 | const state = store.getState(); 26 | store.dispatch(actions.multiServerChannels.set( 27 | calibrateMultiServerChannels(state.ircConfigs) 28 | )); 29 | } 30 | 31 | export function getChannelInfo(channel) { 32 | let uriData = parseChannelUri(channel); 33 | 34 | if (uriData) { 35 | let { channel: channelName, server } = uriData; 36 | return getChannelInfoByNames(server, channelName); 37 | } 38 | 39 | return null; 40 | } 41 | 42 | export function getChannelInfoByNames(serverName, channelName) { 43 | let state = store.getState(); 44 | let config = state.ircConfigs[serverName]; 45 | let channelConfig = config && config.channels[channelName]; 46 | 47 | if (channelConfig) { 48 | let channel = getChannelUri(serverName, channelName); 49 | return { channel, ...channelConfig }; 50 | } 51 | 52 | return null; 53 | } 54 | -------------------------------------------------------------------------------- /serverplugins/twitch/httpRequests.js: -------------------------------------------------------------------------------- 1 | const { URL } = require("url"); 2 | 3 | const _ = require("lodash"); 4 | const request = require("request"); 5 | 6 | // Create a queue to prevent multiple http requests to the same host per second 7 | 8 | const CHECK_REQUESTS_INTERVAL_MS = 500; 9 | 10 | var requestQueue = {}; // Format: { host: [requests...] } 11 | var numQueued = 0; 12 | 13 | function queueRequest(options, callback) { 14 | let urlString = options && options.url; 15 | var url; 16 | 17 | try { 18 | url = new URL(urlString); 19 | } 20 | catch (e) { 21 | // Call immediately and don't give a fuck 22 | return request(options, callback); 23 | } 24 | 25 | let host = url.host || "(anonymous)"; 26 | 27 | if (!requestQueue[host]) { 28 | requestQueue[host] = []; 29 | } 30 | 31 | requestQueue[host].push({ options, callback }); 32 | numQueued++; 33 | } 34 | 35 | function fireQueuedRequests() { 36 | // Take one per host and fire it off 37 | if (numQueued > 0) { 38 | _.forOwn(requestQueue, function(requestList/*, host*/) { 39 | let r = requestList.shift(); 40 | 41 | if (r) { 42 | 43 | /*console.log( 44 | "Firing off request to " + host + 45 | " (" + requestList.length + " remaining, " + 46 | (numQueued - 1) + " total remaining)" 47 | );*/ 48 | 49 | let { options, callback } = r; 50 | request(options, callback); 51 | numQueued--; 52 | } 53 | }); 54 | } 55 | } 56 | 57 | setInterval(fireQueuedRequests, CHECK_REQUESTS_INTERVAL_MS); 58 | 59 | module.exports = { queueRequest }; 60 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | <% 2 | // PYRAMID 3 | // Index page 4 | 5 | var inscriptEscape = (str) => { 6 | if (!str) { 7 | return "undefined"; 8 | } 9 | 10 | return str.replace(/<\/script/g, "\\x3c/script"); 11 | }; 12 | %> 13 | 29 |
30 | 31 | -------------------------------------------------------------------------------- /server/defaults.js: -------------------------------------------------------------------------------- 1 | // THESE ARE DEFAULT SETTINGS, DO NOT CHANGE THIS FILE. 2 | // Change your local settings on your Pyramid settings page. 3 | 4 | module.exports = { 5 | debug: false, 6 | 7 | // Whether or not to store the log files as text files in a folder 8 | logLinesFile: true, 9 | 10 | // HTTPS certificate settings 11 | httpsCertPath: "", 12 | httpsKeyPath: "", 13 | 14 | timeZone: "UTC", 15 | webPort: 54335, 16 | 17 | // Encryption mode 18 | strongIrcPasswordEncryption: false, 19 | 20 | // Scrollback ("cache") length 21 | cacheLines: 150, 22 | 23 | // How much to retain in the db 24 | retainDbValue: 1000000, 25 | retainDbType: 0, 26 | 27 | // Appearance 28 | showUserEvents: 1, 29 | enableUsernameColors: true, 30 | enableDarkMode: false, 31 | enableEmojiCodes: true, 32 | enableEmojiImages: true, 33 | enableDesktopNotifications: true, 34 | showActivityFlashes: true, 35 | 36 | // Twitch 37 | automaticallyJoinTwitchGroupChats: true, 38 | enableTwitch: true, 39 | enableTwitchBadges: true, 40 | enableTwitchColors: true, 41 | enableTwitchChannelDisplayNames: true, 42 | colorBlindness: 0, 43 | enableTwitchUserDisplayNames: 1, 44 | showTwitchDeletedMessages: false, 45 | showTwitchClearChats: false, 46 | showTwitchCheers: 2, 47 | enableFfzEmoticons: true, 48 | enableFfzGlobalEmoticons: true, 49 | enableFfzChannelEmoticons: true, 50 | enableBttvEmoticons: true, 51 | enableBttvGlobalEmoticons: true, 52 | enableBttvChannelEmoticons: true, 53 | enableBttvAnimatedEmoticons: true, 54 | enableBttvPersonalEmoticons: true 55 | }; 56 | -------------------------------------------------------------------------------- /public/src/js/components/TimedChannelItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ChannelLink from "./ChannelLink.jsx"; 5 | import TimedItem from "./TimedItem.jsx"; 6 | import UserLink from "./UserLink.jsx"; 7 | 8 | class TimedChannelItem extends PureComponent { 9 | render() { 10 | const { 11 | channel, 12 | displayName, 13 | displayServer = false, 14 | lastSeenData = {}, 15 | skipOld = false, 16 | visible 17 | } = this.props; 18 | 19 | const prefix = ; 26 | 27 | var suffix = null; 28 | 29 | if (lastSeenData && lastSeenData.username) { 30 | let { username, userDisplayName } = lastSeenData; 31 | 32 | suffix = ( 33 | 34 | by 39 | 40 | ); 41 | } 42 | 43 | return ; 51 | } 52 | } 53 | 54 | TimedChannelItem.propTypes = { 55 | channel: PropTypes.string, 56 | displayName: PropTypes.string, 57 | displayServer: PropTypes.bool, 58 | lastSeenData: PropTypes.object, 59 | skipOld: PropTypes.bool, 60 | visible: PropTypes.bool 61 | }; 62 | 63 | export default TimedChannelItem; 64 | -------------------------------------------------------------------------------- /scripts/updateLocalDatestamps.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment-timezone"); 2 | 3 | const dbSource = require("../server/db"); 4 | const util = require("../server/util"); 5 | 6 | // State 7 | var db; 8 | var timeZone = ""; 9 | 10 | // Util 11 | const localMoment = function(arg) { 12 | if (!timeZone) { 13 | throw new Error("Time zone wasn't set."); 14 | } 15 | 16 | return moment(arg).tz(timeZone); 17 | }; 18 | 19 | const getLocalDatestampFromTime = (time) => { 20 | return util.ymd(localMoment(time)); 21 | }; 22 | 23 | // Load db 24 | dbSource({ localMoment, setDb: (_) => { db = _; } }, () => { 25 | 26 | // Load time zone settings 27 | db.getConfigValue("timeZone", (err, val) => { 28 | if (val && typeof val === "string") { 29 | timeZone = val; 30 | 31 | console.log("Got time zone: " + timeZone); 32 | console.log(); 33 | 34 | // Main action 35 | db._db.each("SELECT lineId, time, date FROM lines", (err, row) => { 36 | if (err) { 37 | console.error("Read error:", err); 38 | } 39 | else if (row && row.lineId && row.time) { 40 | const localDate = getLocalDatestampFromTime(row.time); 41 | if (localDate !== row.date) { 42 | console.log(`Updating ${row.lineId}`); 43 | console.log(`\tLocal date for ${row.time} is ${localDate}`); 44 | 45 | db._db.run("UPDATE lines SET date = $date WHERE lineId = $lineId", { 46 | $date: localDate, 47 | $lineId: row.lineId 48 | }, (err) => { 49 | if (err) { 50 | console.error("Write error:", err); 51 | } 52 | }); 53 | } 54 | } 55 | }); 56 | } 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /scripts/updateDbSchema-2017-05-09.sql: -------------------------------------------------------------------------------- 1 | -- Channels table 2 | BEGIN TRANSACTION; 3 | ALTER TABLE "ircChannels" RENAME TO 'ircChannels_ME_TMP'; 4 | CREATE TABLE "ircChannels" ( 5 | "channelId" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 6 | "serverId" INTEGER NOT NULL REFERENCES "ircServers"("serverId"), 7 | "name" TEXT NOT NULL, 8 | "lastSeenTime" TEXT, 9 | "lastSeenUsername" TEXT, 10 | "lastSeenDisplayName" TEXT, 11 | "isEnabled" INTEGER NOT NULL DEFAULT 1 12 | ); 13 | INSERT INTO "ircChannels" ("channelId", "serverId", "name", "lastSeenTime", "lastSeenUsername", "isEnabled") SELECT "channelId", "serverId", "name", "lastSeenTime", "lastSeenUsername", "isEnabled" FROM "ircChannels_ME_TMP"; 14 | DROP TABLE "ircChannels_ME_TMP"; 15 | CREATE UNIQUE INDEX "serverChannel" ON "ircChannels" ("serverId", "name"); 16 | COMMIT; 17 | 18 | -- Friends table 19 | BEGIN TRANSACTION; 20 | ALTER TABLE "friends" RENAME TO 'friends_ME_TMP'; 21 | CREATE TABLE "friends" ( 22 | "friendId" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, 23 | "serverId" INTEGER NOT NULL, 24 | "username" TEXT NOT NULL, 25 | "lastSeenTime" TEXT, 26 | "lastSeenChannelId" INTEGER, 27 | "isBestFriend" INTEGER DEFAULT 0 NOT NULL, 28 | "isEnabled" INTEGER DEFAULT 1 NOT NULL, 29 | "displayName" TEXT 30 | ); 31 | INSERT INTO "friends" ("friendId", "serverId", "username", "lastSeenTime", "lastSeenChannelId", "isBestFriend", "isEnabled") SELECT "friendId", "serverId", "username", "lastSeenTime", "lastSeenChannelId", "isBestFriend", "isEnabled" FROM "friends_ME_TMP"; 32 | DROP TABLE "friends_ME_TMP"; 33 | CREATE UNIQUE INDEX "serverUser" ON "friends" ("serverId", "username"); 34 | COMMIT; 35 | -------------------------------------------------------------------------------- /server/constants.js: -------------------------------------------------------------------------------- 1 | // PYRAMID 2 | // Constants 3 | 4 | const path = require("path"); 5 | 6 | const DATA_ROOT = path.join(__dirname, "..", "data"); 7 | 8 | module.exports = { 9 | DEBUG: false, 10 | FILE_ENCODING: "utf8", 11 | 12 | PROJECT_ROOT: path.join(__dirname, ".."), 13 | DATA_ROOT, 14 | LOG_ROOT: path.join(__dirname, "..", "public", "data", "logs"), 15 | 16 | DB_FILENAME: path.join(DATA_ROOT, "pyramid.db"), 17 | 18 | RELATIONSHIP_NONE: 0, 19 | RELATIONSHIP_FRIEND: 1, 20 | RELATIONSHIP_BEST_FRIEND: 2, 21 | 22 | CACHE_LINES: 150, 23 | CONTEXT_CACHE_LINES: 40, 24 | CONTEXT_CACHE_MINUTES: 60, 25 | LAST_SEEN_UPDATE_RATE: 500, 26 | LOG_PAGE_SIZE: 300, 27 | BUNCHED_EVENT_SIZE: 50, 28 | 29 | USER_MODIFYING_EVENT_TYPES: 30 | ["join", "part", "quit", "kick", "kill", "mode"], 31 | PART_EVENT_TYPES: 32 | ["part", "quit", "kick", "kill"], 33 | BUNCHABLE_EVENT_TYPES: 34 | ["join", "part", "quit", "kill", "mode"], 35 | 36 | SUPPORTED_CATEGORY_NAMES: ["highlights", "allfriends", "system"], 37 | 38 | TOKEN_COOKIE_NAME: "token", 39 | TOKEN_COOKIE_SECONDS: 86400 * 365, 40 | 41 | PAGE_TYPES: { 42 | CATEGORY: "category", 43 | CHANNEL: "channel", 44 | USER: "user" 45 | }, 46 | 47 | CONNECTION_STATUS: { 48 | ABORTED: "aborted", 49 | CONNECTED: "connected", 50 | DISCONNECTED: "disconnected", 51 | FAILED: "failed", 52 | REJECTED: "rejected" 53 | }, 54 | 55 | CHANNEL_TYPES: { 56 | PUBLIC: 0, 57 | PRIVATE: 1 58 | }, 59 | 60 | USER_EVENT_VISIBILITY: { 61 | OFF: 0, 62 | COLLAPSE_PRESENCE: 1, 63 | COLLAPSE_MESSAGES: 2, 64 | SHOW_ALL: 3 65 | }, 66 | 67 | RETAIN_DB_TYPES: { 68 | LINES: 0, 69 | DAYS: 1 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /server/routes/login.js: -------------------------------------------------------------------------------- 1 | const constants = require("../constants"); 2 | const passwordUtils = require("../util/passwords"); 3 | const routeUtils = require("../util/routing"); 4 | const tokenUtils = require("../util/tokens"); 5 | 6 | module.exports = function(main) { 7 | 8 | function get(req, res) { 9 | const config = main.appConfig().currentAppConfig(); 10 | if (!config || !config.webPassword) { 11 | res.redirect("/welcome"); 12 | } 13 | else if (!routeUtils.isLoggedIn(req, res)) { 14 | res.render("login", { appConfig: null, enableScripts: false }); 15 | } 16 | else { 17 | routeUtils.redirectBack(req, res); 18 | } 19 | } 20 | 21 | function post(req, res) { 22 | let passwordHash = main.appConfig().currentAppConfig().webPassword; 23 | if ( 24 | req.body && 25 | passwordUtils.verifyPassword(req.body.password, passwordHash) 26 | ) { 27 | 28 | main.ircPasswords().onDecryptionKey(req.body.password); 29 | 30 | if (req.body.logOutOtherSessions) { 31 | tokenUtils.clearAcceptedTokens(); 32 | } 33 | 34 | const token = tokenUtils.generateAcceptedToken(); 35 | routeUtils.setTokenCookie( 36 | res, 37 | token, 38 | { 39 | httpOnly: true, 40 | maxAge: constants.TOKEN_COOKIE_SECONDS 41 | } 42 | ); 43 | 44 | routeUtils.redirectBack(req, res); 45 | return; 46 | } 47 | 48 | // If you don't get the password right, punish the user with waiting time 49 | // TODO: Increase this time with multiple bad attempts by the same person 50 | 51 | setTimeout(() => { 52 | res.render("unauthorized", { appConfig: null, enableScripts: false }); 53 | }, 3000); 54 | } 55 | 56 | return { get, post }; 57 | }; 58 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatUsername.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { fixColorContrast } from "../../lib/color"; 5 | 6 | import UserLink from "../../components/UserLink.jsx"; 7 | 8 | class ChatUsername extends PureComponent { 9 | render() { 10 | const { 11 | className: givenClassName = "", 12 | color, 13 | colorBlindness, 14 | displayName, 15 | enableDarkMode, 16 | serverName, 17 | symbol = "", 18 | username 19 | } = this.props; 20 | 21 | var className = "chatusername " + givenClassName; 22 | 23 | const styles = {}; 24 | 25 | // Numbered color 26 | 27 | if (color && typeof color === "number" && color > 0) { 28 | className += " chatusername--color-" + color; 29 | } 30 | 31 | // String color 32 | 33 | if (color && typeof color === "string") { 34 | let fixedColors = fixColorContrast(color, colorBlindness); 35 | styles.color = enableDarkMode ? fixedColors.dark : fixedColors.light; 36 | } 37 | 38 | return ( 39 | 40 | { symbol } 41 | 46 | 47 | ); 48 | } 49 | } 50 | 51 | ChatUsername.propTypes = { 52 | color: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 53 | colorBlindness: PropTypes.number, 54 | className: PropTypes.string, 55 | displayName: PropTypes.string, 56 | enableDarkMode: PropTypes.bool, 57 | serverName: PropTypes.string, 58 | symbol: PropTypes.string, 59 | username: PropTypes.string.isRequired 60 | }; 61 | 62 | export default ChatUsername; 63 | -------------------------------------------------------------------------------- /public/src/js/components/HighlightsLink.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import ChatViewLink from "./ChatViewLink.jsx"; 6 | import { CATEGORY_NAMES, PAGE_TYPES } from "../constants"; 7 | 8 | const block = "highlightslink"; 9 | 10 | class HighlightsLink extends PureComponent { 11 | render() { 12 | const { 13 | className: givenClassName, 14 | noText = false, 15 | unseenHighlights 16 | } = this.props; 17 | 18 | var badge = null; 19 | 20 | if (unseenHighlights && unseenHighlights.length) { 21 | badge = ( 22 | 23 | { unseenHighlights.length } 24 | 25 | ); 26 | } 27 | 28 | let className = block + 29 | (badge ? ` ${block}--highlighted` : "") + 30 | (givenClassName ? " " + givenClassName : ""); 31 | 32 | if (noText) { 33 | if (badge) { 34 | return ( 35 | 39 | { badge } 40 | 41 | ); 42 | } 43 | 44 | return null; 45 | } 46 | 47 | return ( 48 | 52 | { CATEGORY_NAMES.highlights } 53 | { badge } 54 | 55 | ); 56 | } 57 | } 58 | 59 | HighlightsLink.propTypes = { 60 | className: PropTypes.string, 61 | noText: PropTypes.bool, 62 | unseenHighlights: PropTypes.array 63 | }; 64 | 65 | export default connect(({ 66 | unseenHighlights 67 | }) => ({ 68 | unseenHighlights 69 | }))(HighlightsLink); 70 | -------------------------------------------------------------------------------- /public/src/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | 3 | import appConfig from "./appConfig"; 4 | import categoryCaches from "./categoryCaches"; 5 | import channelCaches from "./channelCaches"; 6 | import channelData from "./channelData"; 7 | import channelUserLists from "./channelUserLists"; 8 | import connectionStatus from "./connectionStatus"; 9 | import deviceState from "./deviceState"; 10 | import friendsList from "./friendsList"; 11 | import ircConfigs from "./ircConfigs"; 12 | import lastSeenChannels from "./lastSeenChannels"; 13 | import lastSeenUsers from "./lastSeenUsers"; 14 | import lineInfo from "./lineInfo"; 15 | import logDetails from "./logDetails"; 16 | import logFiles from "./logFiles"; 17 | import multiServerChannels from "./multiServerChannels"; 18 | import nicknames from "./nicknames"; 19 | import onlineFriends from "./onlineFriends"; 20 | import offlineMessages from "./offlineMessages"; 21 | import serverData from "./serverData"; 22 | import systemInfo from "./systemInfo"; 23 | import token from "./token"; 24 | import unseenConversations from "./unseenConversations"; 25 | import unseenHighlights from "./unseenHighlights"; 26 | import userCaches from "./userCaches"; 27 | import viewState from "./viewState"; 28 | 29 | export default combineReducers({ 30 | appConfig, 31 | categoryCaches, 32 | channelCaches, 33 | channelData, 34 | channelUserLists, 35 | connectionStatus, 36 | deviceState, 37 | friendsList, 38 | ircConfigs, 39 | lastSeenChannels, 40 | lastSeenUsers, 41 | lineInfo, 42 | logDetails, 43 | logFiles, 44 | multiServerChannels, 45 | nicknames, 46 | onlineFriends, 47 | offlineMessages, 48 | serverData, 49 | systemInfo, 50 | token, 51 | unseenConversations, 52 | unseenHighlights, 53 | userCaches, 54 | viewState 55 | }); 56 | -------------------------------------------------------------------------------- /server/util/routing.js: -------------------------------------------------------------------------------- 1 | const cookie = require("cookie"); 2 | 3 | const constants = require("../constants"); 4 | const tokenUtils = require("./tokens"); 5 | 6 | const getUsedToken = function(req) { 7 | var cookies = cookie.parse(req.headers.cookie || ""); 8 | if (cookies && cookies[constants.TOKEN_COOKIE_NAME]) { 9 | return cookies[constants.TOKEN_COOKIE_NAME]; 10 | } 11 | 12 | return null; 13 | }; 14 | 15 | const isLoggedIn = function(req) { 16 | const token = getUsedToken(req); 17 | if (token) { 18 | return tokenUtils.isAnAcceptedToken(token); 19 | } 20 | 21 | return false; 22 | }; 23 | 24 | const denyAccessWithoutToken = function(req, res, main) { 25 | 26 | const loggedIn = isLoggedIn(req); 27 | 28 | if (loggedIn) { 29 | return loggedIn; 30 | } 31 | 32 | // If we have no password, redirect to welcome page 33 | const config = main.appConfig().currentAppConfig(); 34 | if (!config || !config.webPassword) { 35 | res.redirect("/welcome"); 36 | } 37 | 38 | // Otherwise, redirect to login page 39 | else { 40 | res.redirect("/login?redirect=" + encodeURIComponent(req.url)); 41 | } 42 | 43 | res.end(); 44 | return false; 45 | }; 46 | 47 | const setTokenCookie = function(res, value, params) { 48 | return res.set( 49 | "Set-Cookie", 50 | cookie.serialize( 51 | constants.TOKEN_COOKIE_NAME, 52 | value, 53 | params 54 | ) 55 | ); 56 | }; 57 | 58 | const redirectBack = function(req, res) { 59 | if ( 60 | // Internal links only 61 | req.query && 62 | req.query.redirect && 63 | req.query.redirect[0] === "/" 64 | ) { 65 | res.redirect(req.query.redirect); 66 | } 67 | else { 68 | res.redirect("/"); 69 | } 70 | }; 71 | 72 | module.exports = { 73 | denyAccessWithoutToken, 74 | getUsedToken, 75 | isLoggedIn, 76 | redirectBack, 77 | setTokenCookie 78 | }; 79 | -------------------------------------------------------------------------------- /public/src/js/lib/posting.js: -------------------------------------------------------------------------------- 1 | import { sendMessage } from "./io"; 2 | import { getMyIrcNickFromChannel } from "../lib/connectionStatus"; 3 | 4 | import actions from "../actions"; 5 | import store from "../store"; 6 | 7 | function generateToken(length = 60) { 8 | const alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 9 | var output = ""; 10 | for (var i = 0; i < length; i++) { 11 | output += alpha[Math.round((alpha.length-1)*Math.random())]; 12 | } 13 | return output; 14 | } 15 | 16 | function prepareOfflineMessage(messageData) { 17 | let { message } = messageData; 18 | let meRegex = /^\/me\s+/; 19 | 20 | if (meRegex.test(message)) { 21 | return { 22 | ...messageData, 23 | message: message.replace(meRegex, ""), 24 | type: "action" 25 | }; 26 | } 27 | 28 | return messageData; 29 | } 30 | 31 | export function postMessage(channel, message) { 32 | let messageToken = generateToken(); 33 | let username = getMyIrcNickFromChannel(channel); 34 | let messageData = prepareOfflineMessage({ 35 | channel, 36 | offline: true, 37 | origMessage: message, 38 | message, 39 | messageToken, 40 | time: new Date().toISOString(), 41 | type: "msg", 42 | username 43 | }); 44 | 45 | store.dispatch(actions.offlineMessages.add(channel, messageToken, messageData)); 46 | sendMessage(channel, message, messageToken); 47 | } 48 | 49 | export function resendOfflineMessage(channel, messageToken) { 50 | let state = store.getState(); 51 | let messageData = state.offlineMessages[channel] && 52 | state.offlineMessages[channel][messageToken]; 53 | 54 | if (messageData) { 55 | store.dispatch(actions.offlineMessages.remove(channel, messageToken)); 56 | postMessage(channel, messageData.origMessage); 57 | } 58 | else { 59 | console.warn("Tried to resend offline message that did not exist"); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /public/src/js/constants.js: -------------------------------------------------------------------------------- 1 | import values from "lodash/values"; 2 | 3 | // Constants 4 | 5 | export const VERSION = "1.0 prerelease"; 6 | 7 | export const DEFAULT_TIMEZONE = "UTC"; 8 | export const ROOT_PATHNAME = ""; 9 | 10 | export const CACHE_LINES = 150; 11 | export const LOG_PAGE_SIZE = 300; 12 | 13 | export const RELATIONSHIP_NONE = 0; 14 | export const RELATIONSHIP_FRIEND = 1; 15 | export const RELATIONSHIP_BEST_FRIEND = 2; 16 | 17 | export const PAGE_TYPES = { 18 | CATEGORY: "category", 19 | CHANNEL: "channel", 20 | USER: "user" 21 | }; 22 | 23 | export const PAGE_TYPE_NAMES = values(PAGE_TYPES); 24 | 25 | export const CATEGORY_NAMES = { 26 | allfriends: "All friends", 27 | highlights: "Highlights", 28 | system: "System log" 29 | }; 30 | 31 | export const SETTINGS_PAGE_NAMES = { 32 | general: "General", 33 | friends: "Friends", 34 | irc: "IRC", 35 | nicknames: "Nicknames", 36 | twitch: "Twitch" 37 | }; 38 | 39 | export const CHANGE_DEBOUNCE_MS = 1300; 40 | export const INPUT_SELECTOR = "input, select, textarea"; 41 | 42 | export const TWITCH_DISPLAY_NAMES = { 43 | OFF: 0, 44 | CASE_CHANGES_ONLY: 1, 45 | ALL: 2 46 | }; 47 | 48 | export const ACTIVITY_COLOR_RGB = "0,0,51"; 49 | export const DARKMODE_ACTIVITY_COLOR_RGB = "119,187,238"; 50 | export const BG_COLOR = "#fff"; 51 | export const DARKMODE_BG_COLOR = "#2b2b33"; 52 | export const FG_COLOR = "#222227"; 53 | export const DARKMODE_FG_COLOR = "#ccc"; 54 | export const INVERTED_FG_COLOR = "#fff"; 55 | export const DARKMODE_INVERTED_FG_COLOR = "#222227"; 56 | 57 | export const COLOR_BLINDNESS = { 58 | OFF: 0, 59 | PROTANOPE: 1, 60 | DEUTERANOPE: 2, 61 | TRITANOPE: 3 62 | }; 63 | 64 | export const CHANNEL_TYPES = { 65 | PUBLIC: 0, 66 | PRIVATE: 1 67 | }; 68 | 69 | export const TWITCH_CHEER_DISPLAY = { 70 | OFF: 0, 71 | STATIC: 1, 72 | ANIMATED: 2 73 | }; 74 | 75 | export const USER_EVENT_VISIBILITY = { 76 | OFF: 0, 77 | COLLAPSE_PRESENCE: 1, 78 | COLLAPSE_MESSAGES: 2, 79 | SHOW_ALL: 3 80 | }; 81 | -------------------------------------------------------------------------------- /public/src/scss/tooltips.scss: -------------------------------------------------------------------------------- 1 | $tipsy-bg: rgba(0,0,0,.8); 2 | 3 | .tooltip-secondary { 4 | font-size: .625rem; 5 | } 6 | 7 | /** 8 | * React Tipsy 9 | * =========== 10 | */ 11 | .Tipsy { 12 | display: block; 13 | position: absolute; 14 | z-index: 1070; 15 | font-family: inherit; 16 | font-style: normal; 17 | font-weight: normal; 18 | letter-spacing: normal; 19 | line-break: auto; 20 | line-height: 1.42857143; 21 | text-align: left; 22 | text-align: start; 23 | text-decoration: none; 24 | text-shadow: none; 25 | text-transform: none; 26 | white-space: normal; 27 | word-break: normal; 28 | word-spacing: normal; 29 | word-wrap: normal; 30 | font-size: 100%; 31 | opacity: 0; 32 | filter: alpha(opacity=0); 33 | } 34 | .Tipsy.in { 35 | opacity: 1; 36 | filter: alpha(opacity=100); 37 | } 38 | .Tipsy.top { 39 | margin-top: -3px; 40 | padding: 5px 0; 41 | } 42 | .Tipsy.right { 43 | margin-left: 3px; 44 | padding: 0 5px; 45 | } 46 | .Tipsy.bottom { 47 | margin-top: 3px; 48 | padding: 5px 0; 49 | } 50 | .Tipsy.left { 51 | margin-left: -3px; 52 | padding: 0 5px; 53 | } 54 | .Tipsy-inner { 55 | background-color: $tipsy-bg; 56 | color: #fff; 57 | max-width: 200px; 58 | padding: 3px 8px; 59 | text-align: center; 60 | } 61 | .Tipsy-arrow { 62 | border-color: transparent; 63 | border-style: solid; 64 | height: 0; 65 | position: absolute; 66 | width: 0; 67 | } 68 | .Tipsy.top .Tipsy-arrow { 69 | bottom: 0; 70 | left: 50%; 71 | margin-left: -5px; 72 | border-width: 5px 5px 0; 73 | border-top-color: $tipsy-bg; 74 | } 75 | .Tipsy.right .Tipsy-arrow { 76 | top: 50%; 77 | left: 0; 78 | margin-top: -5px; 79 | border-width: 5px 5px 5px 0; 80 | border-right-color: $tipsy-bg; 81 | } 82 | .Tipsy.left .Tipsy-arrow { 83 | top: 50%; 84 | right: 0; 85 | margin-top: -5px; 86 | border-width: 5px 0 5px 5px; 87 | border-left-color: $tipsy-bg; 88 | } 89 | .Tipsy.bottom .Tipsy-arrow { 90 | top: 0; 91 | left: 50%; 92 | margin-left: -5px; 93 | border-width: 0 5px 5px; 94 | border-bottom-color: $tipsy-bg; 95 | } 96 | -------------------------------------------------------------------------------- /serverplugins/twitch/userStates.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | const twitchApiData = require("./twitchApiData"); 4 | 5 | var userStates = {}; 6 | var globalUserStates = {}; 7 | 8 | const getUserState = function(channel) { 9 | return userStates[channel]; 10 | }; 11 | 12 | const getGlobalUserState = function(serverName) { 13 | return globalUserStates[serverName]; 14 | }; 15 | 16 | const setUserState = function(channel, state) { 17 | userStates[channel] = state; 18 | }; 19 | 20 | const setGlobalUserState = function(serverName, state) { 21 | globalUserStates[serverName] = state; 22 | }; 23 | 24 | // Getting the "most average" user state for when the server lets us down... 25 | 26 | const userStateSpecialness = function(state) { 27 | // (less is more average) 28 | 29 | let specialness = 0; 30 | 31 | // The less badges you have, the more average it must be, right? 32 | 33 | if (state && state.badges) { 34 | specialness = state.badges.length || 0; 35 | } 36 | 37 | return specialness; 38 | }; 39 | 40 | const getAverageUserState = function() { 41 | let lowestValue, lowestState; 42 | 43 | _.forOwn(userStates, (state) => { 44 | if (state) { 45 | let specialness = userStateSpecialness(state); 46 | 47 | if (typeof lowestValue === "undefined" || lowestValue > specialness) { 48 | lowestValue = specialness; 49 | lowestState = state; 50 | } 51 | } 52 | }); 53 | 54 | return lowestState; 55 | }; 56 | 57 | const handleNewUserState = function(data) { 58 | let { channel, message, serverName } = data; 59 | let { command, tags } = message; 60 | 61 | if (command === "GLOBALUSERSTATE") { 62 | setGlobalUserState(serverName, tags); 63 | } 64 | else { 65 | setUserState(channel, tags); 66 | } 67 | 68 | if (tags["emote-sets"]) { 69 | twitchApiData.requestEmoticonImagesIfNeeded( 70 | tags["emote-sets"] 71 | ); 72 | } 73 | }; 74 | 75 | module.exports = { 76 | getAverageUserState, 77 | getGlobalUserState, 78 | getUserState, 79 | handleNewUserState, 80 | setGlobalUserState, 81 | setUserState 82 | }; 83 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/Emoji.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import Tipsy from "react-tipsy"; 4 | 5 | import { refElSetter } from "../../lib/refEls"; 6 | 7 | const emojiImageUrl = function(codepoints) { 8 | return `https://twemoji.maxcdn.com/2/svg/${codepoints}.svg`; 9 | }; 10 | 11 | class Emoji extends PureComponent { 12 | constructor(props) { 13 | super(props); 14 | 15 | this.onLoad = this.onLoad.bind(this); 16 | this.onTooltipLoad = this.onTooltipLoad.bind(this); 17 | 18 | this.els = {}; 19 | this.setTooltip = refElSetter("tooltip").bind(this); 20 | } 21 | 22 | onLoad() { 23 | let { onLoad } = this.props; 24 | if (typeof onLoad === "function") { 25 | onLoad(); 26 | } 27 | } 28 | 29 | onTooltipLoad() { 30 | let { tooltip } = this.els; 31 | 32 | if (tooltip) { 33 | tooltip.updatePosition(); 34 | } 35 | } 36 | 37 | renderEmoji(variant = "") { 38 | let { codepoints, enableEmojiImages, text } = this.props; 39 | let className = "emoji" + (variant ? "-" + variant : ""); 40 | 41 | if (enableEmojiImages) { 42 | return {text}; 48 | } 49 | 50 | return ( 51 | 52 | { text } 53 | 54 | ); 55 | } 56 | 57 | render() { 58 | let { enableEmojiCodes, name } = this.props; 59 | 60 | let tooltipContent = [ 61 | this.renderEmoji("large"), 62 | (enableEmojiCodes && name 63 | ?
{ `:${name}:` }
: null) 64 | ]; 65 | 66 | return ( 67 | 68 | { this.renderEmoji() } 69 | 70 | ); 71 | } 72 | } 73 | 74 | Emoji.propTypes = { 75 | codepoints: PropTypes.string, 76 | enableEmojiCodes: PropTypes.bool, 77 | enableEmojiImages: PropTypes.bool, 78 | onLoad: PropTypes.func, 79 | name: PropTypes.string, 80 | text: PropTypes.string 81 | }; 82 | 83 | export default Emoji; 84 | -------------------------------------------------------------------------------- /public/src/js/twitch/TwitchCheermote.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import TwitchEmoticon from "./TwitchEmoticon.jsx"; 6 | import { TWITCH_CHEER_DISPLAY } from "../constants"; 7 | import { fixColorContrast } from "../lib/color"; 8 | 9 | const block = "twitch-cheermote"; 10 | 11 | class TwitchCheermote extends PureComponent { 12 | render() { 13 | 14 | let { 15 | amount, 16 | color, 17 | colorBlindness, 18 | enableDarkMode, 19 | images, 20 | onLoad, 21 | showTwitchCheers, 22 | text 23 | } = this.props; 24 | 25 | if (!showTwitchCheers) { 26 | return ( 27 | 28 | { text } 29 | 30 | ); 31 | } 32 | 33 | let styles = {}; 34 | 35 | if (color && typeof color === "string") { 36 | let fixedColor = fixColorContrast(color, colorBlindness); 37 | styles.color = enableDarkMode ? fixedColor.dark : fixedColor.light; 38 | } 39 | 40 | let lightness = enableDarkMode ? "dark" : "light"; 41 | let displayPref = showTwitchCheers === TWITCH_CHEER_DISPLAY.ANIMATED 42 | ? "animated" : "static"; 43 | 44 | let urlSet = images[lightness][displayPref]; 45 | 46 | return ( 47 | 48 | 53 | { amount.toLocaleString() } 54 | 55 | ); 56 | } 57 | } 58 | 59 | TwitchCheermote.propTypes = { 60 | amount: PropTypes.number, 61 | color: PropTypes.string, 62 | colorBlindness: PropTypes.number, 63 | enableDarkMode: PropTypes.bool, 64 | images: PropTypes.object, 65 | onLoad: PropTypes.func, 66 | showTwitchCheers: PropTypes.number, 67 | text: PropTypes.string 68 | }; 69 | 70 | export default connect(({ 71 | appConfig: { 72 | colorBlindness, 73 | enableDarkMode, 74 | showTwitchCheers 75 | } 76 | }) => ({ 77 | colorBlindness, 78 | enableDarkMode, 79 | showTwitchCheers 80 | }))(TwitchCheermote); 81 | -------------------------------------------------------------------------------- /server/main/nicknames.js: -------------------------------------------------------------------------------- 1 | const channelUtils = require("../util/channels"); 2 | 3 | module.exports = function(db) { 4 | 5 | var currentNicknames = []; 6 | 7 | const getNicknames = function(callback) { 8 | db.getNicknames(callback); 9 | }; 10 | 11 | const loadNicknames = function(callback) { 12 | getNicknames((err, data) => { 13 | if (err) { 14 | if (typeof callback === "function") { 15 | callback(err); 16 | } 17 | } 18 | else { 19 | currentNicknames = data; 20 | if (typeof callback === "function") { 21 | callback(null, data); 22 | } 23 | } 24 | }); 25 | }; 26 | 27 | const nicknamesDict = function(nicknames = currentNicknames) { 28 | const out = {}; 29 | nicknames.forEach((nickname) => { 30 | out[nickname.nickname] = nickname; 31 | }); 32 | 33 | return out; 34 | }; 35 | 36 | const addNickname = function(nickname, callback) { 37 | db.addNickname(nickname, callback); 38 | }; 39 | 40 | const modifyNickname = function(nickname, data, callback) { 41 | db.modifyNickname(nickname, data, callback); 42 | }; 43 | 44 | const removeNickname = function(nickname, callback) { 45 | db.removeNickname(nickname, callback); 46 | }; 47 | 48 | const getHighlightStringsForMessage = function(message, channel, meUsername) { 49 | var highlightStrings = []; 50 | 51 | const meRegex = new RegExp("\\b" + meUsername + "\\b", "i"); 52 | if (meRegex.test(message)) { 53 | highlightStrings.push(meUsername); 54 | } 55 | 56 | currentNicknames.forEach((nickname) => { 57 | const nickRegex = new RegExp("\\b" + nickname.nickname + "\\b", "i"); 58 | if ( 59 | nickRegex.test(message) && 60 | channelUtils.passesChannelWhiteBlacklist(nickname, channel) 61 | ) { 62 | highlightStrings.push(nickname.nickname); 63 | } 64 | }); 65 | 66 | return highlightStrings; 67 | }; 68 | 69 | return { 70 | addNickname, 71 | currentNicknames: () => currentNicknames, 72 | getHighlightStringsForMessage, 73 | getNicknames, 74 | loadNicknames, 75 | modifyNickname, 76 | nicknamesDict, 77 | removeNickname 78 | }; 79 | }; 80 | -------------------------------------------------------------------------------- /public/src/js/components/UserList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import SortedItemList from "./SortedItemList.jsx"; 6 | import TimedUserItem from "./TimedUserItem.jsx"; 7 | 8 | const sortableName = function(item) { 9 | let name = item.username; 10 | return name.toLowerCase(); 11 | }; 12 | 13 | const getDataUsername = function(data) { 14 | return data && data.username; 15 | }; 16 | 17 | class UserList extends PureComponent { 18 | constructor(props) { 19 | super(props); 20 | 21 | this.renderUserItem = this.renderUserItem.bind(this); 22 | } 23 | 24 | renderUserItem(data) { 25 | let { 26 | hideOldUsers = true, 27 | ignoreUsernames = [], 28 | visible 29 | } = this.props; 30 | 31 | if ( 32 | data && 33 | data.username && 34 | ignoreUsernames.indexOf(data.username) < 0 35 | ) { 36 | let { username, lastSeen = {} } = data; 37 | return ; 45 | } 46 | 47 | return null; 48 | } 49 | 50 | render() { 51 | const { lastSeenUsers, sort } = this.props; 52 | 53 | let list = []; 54 | for (var username in lastSeenUsers) { 55 | let lastSeen = lastSeenUsers[username]; 56 | list.push({ username, lastSeen }); 57 | } 58 | 59 | return ; 68 | } 69 | } 70 | 71 | UserList.propTypes = { 72 | hideOldUsers: PropTypes.bool, 73 | ignoreUsernames: PropTypes.array, 74 | lastSeenUsers: PropTypes.object, 75 | sort: PropTypes.string, 76 | visible: PropTypes.bool 77 | }; 78 | 79 | export default connect(({ 80 | appConfig: { hideOldUsers }, 81 | lastSeenUsers 82 | }) => ({ 83 | hideOldUsers, 84 | lastSeenUsers 85 | }))(UserList); 86 | -------------------------------------------------------------------------------- /public/src/js/lib/emojis.js: -------------------------------------------------------------------------------- 1 | import emojiData from "./emojiData"; 2 | 3 | const colonCodeRegex = /:([^:\s]+):/g; 4 | 5 | // This code borrowed by FFZ, which says: 6 | // This code borrowed from the twemoji project, with tweaks. 7 | 8 | function codepointToEmoji(codepoint) { 9 | var code = typeof codepoint === "string" ? parseInt(codepoint, 16) : codepoint; 10 | 11 | if (code < 0x10000) { 12 | return String.fromCharCode(code); 13 | } 14 | 15 | code -= 0x10000; 16 | return String.fromCharCode( 17 | 0xd800 + (code >> 10), 18 | 0xdc00 + (code & 0x3ff) 19 | ); 20 | } 21 | 22 | const ufe0fg = /\ufe0f/g; 23 | const u200d = String.fromCharCode(0x200d); 24 | 25 | const cachedEmojiCodepoints = {}; 26 | 27 | export function emojiToCodepoint(surrogates) { 28 | if (cachedEmojiCodepoints[surrogates]) { 29 | return cachedEmojiCodepoints[surrogates]; 30 | } 31 | 32 | const input = surrogates.indexOf(u200d) === -1 33 | ? surrogates.replace(ufe0fg, "") 34 | : surrogates; 35 | var out = [], c = 0, p = 0, i = 0; 36 | 37 | while (i < input.length) { 38 | c = input.charCodeAt(i++); 39 | if (p) { 40 | out.push( 41 | (0x10000 + ((p - 0xd800) << 10) + (c - 0xdc00)) 42 | .toString(16) 43 | ); 44 | p = 0; 45 | } else if (0xd800 <= c && c <= 0xdbff) { 46 | p = c; 47 | } 48 | else { 49 | out.push(c.toString(16)); 50 | } 51 | } 52 | 53 | const retval = cachedEmojiCodepoints[surrogates] = out.join("-"); 54 | return retval; 55 | } 56 | 57 | export function convertCodesToEmojis (text, extraInfo = false) { 58 | var matchIndex = -1, replacementLength = 0; 59 | if (emojiData) { 60 | text = text.replace(colonCodeRegex, (code, name) => { 61 | if (emojiData[name]) { 62 | let emoji = emojiData[name].split("-").map(codepointToEmoji).join(""); 63 | 64 | matchIndex = text.indexOf(code); 65 | replacementLength = emoji.length; 66 | 67 | return emoji; 68 | } 69 | 70 | return code; 71 | }); 72 | } 73 | 74 | if (extraInfo) { 75 | return { 76 | matchIndex, 77 | replacementLength, 78 | result: text 79 | }; 80 | } 81 | 82 | return text; 83 | } 84 | -------------------------------------------------------------------------------- /public/src/js/reducers/userCaches.js: -------------------------------------------------------------------------------- 1 | import clone from "lodash/clone"; 2 | import omit from "lodash/omit"; 3 | 4 | import * as actionTypes from "../actionTypes"; 5 | import { startExpiringUserCache } from "../lib/dataExpiration"; 6 | import { cacheMessageItem } from "../lib/messageCaches"; 7 | 8 | const userCachesInitialState = {}; 9 | 10 | export default function (state = userCachesInitialState, action) { 11 | 12 | switch (action.type) { 13 | case actionTypes.userCaches.UPDATE: { 14 | let { username, cache } = action; 15 | let item = state[username] || {}; 16 | return { 17 | ...state, 18 | [username]: { 19 | ...item, 20 | cache, 21 | lastReload: new Date() 22 | } 23 | }; 24 | } 25 | 26 | case actionTypes.userCaches.APPEND: { 27 | let s = clone(state), d = action.data; 28 | 29 | if (!s[d.username]) { 30 | s[d.username] = { cache: [] }; 31 | } 32 | 33 | // Simpler append logic than channelCaches, 34 | // due to this stream only including message events 35 | 36 | s[d.username].cache = cacheMessageItem(s[d.username].cache, d); 37 | return s; 38 | } 39 | 40 | case actionTypes.userCaches.REMOVE: { 41 | let { username } = action; 42 | return omit(state, username); 43 | } 44 | 45 | case actionTypes.userCaches.STARTEXPIRATION: { 46 | let { username } = action; 47 | let item = state[username]; 48 | if (item) { 49 | if (item.expirationTimer) { 50 | clearTimeout(item.expirationTimer); 51 | } 52 | 53 | return { 54 | ...state, 55 | [username]: { 56 | ...item, 57 | expirationTimer: startExpiringUserCache(username) 58 | } 59 | }; 60 | } 61 | break; 62 | } 63 | 64 | case actionTypes.userCaches.STOPEXPIRATION: { 65 | let { username } = action; 66 | let item = state[username]; 67 | if (item) { 68 | if (item.expirationTimer) { 69 | clearTimeout(item.expirationTimer); 70 | } 71 | 72 | return { 73 | ...state, 74 | [username]: { 75 | ...item, 76 | expirationTimer: null 77 | } 78 | }; 79 | } 80 | } 81 | } 82 | 83 | return state; 84 | } 85 | -------------------------------------------------------------------------------- /public/src/js/lib/chatEvents.js: -------------------------------------------------------------------------------- 1 | import pickBy from "lodash/pickBy"; 2 | 3 | const PART_EVENT_TYPES = ["part", "quit", "kick", "kill"]; 4 | const JOIN_ADDEND = 1; 5 | const PART_ADDEND = -1; 6 | 7 | export function prepareBunchedEvents(event, collapseJoinParts) { 8 | var joins = [], 9 | parts = [], 10 | eventOrder = [], 11 | earliestTime = event.firstTime, 12 | latestTime; 13 | 14 | const overloaded = 15 | event.events.length < event.joinCount + event.partCount; 16 | 17 | event.events.sort((a, b) => a.time - b.time); 18 | 19 | const userStatus = {}; 20 | const userInfos = {}; 21 | 22 | const addToLists = (list, addendValue, username, displayName) => { 23 | let userInfo = { displayName, username }; 24 | list.push(userInfo); 25 | userInfos[username] = userInfo; 26 | 27 | if (collapseJoinParts) { 28 | userStatus[username] += addendValue; 29 | } 30 | }; 31 | 32 | event.events.forEach((event) => { 33 | if (event) { 34 | let { displayName, time, type, username } = event; 35 | 36 | if (!(username in userStatus)) { 37 | userStatus[username] = 0; 38 | } 39 | 40 | const eventName = type === "join" ? "join" : "part"; 41 | if (type === "join") { 42 | addToLists(joins, JOIN_ADDEND, username, displayName); 43 | } 44 | else if (PART_EVENT_TYPES.indexOf(type) >= 0) { 45 | addToLists(parts, PART_ADDEND, username, displayName); 46 | } 47 | else { 48 | return; 49 | } 50 | 51 | if (eventOrder.indexOf(eventName) < 0) { 52 | eventOrder.push(eventName); 53 | } 54 | 55 | if (!earliestTime || time < earliestTime) { 56 | earliestTime = time; 57 | } 58 | 59 | if (!latestTime || time > latestTime) { 60 | latestTime = time; 61 | } 62 | } 63 | }); 64 | 65 | if (collapseJoinParts && !overloaded) { 66 | joins = Object.keys(pickBy(userStatus, (value) => value > 0)) 67 | .map((username) => userInfos[username]); 68 | parts = Object.keys(pickBy(userStatus, (value) => value < 0)) 69 | .map((username) => userInfos[username]); 70 | } 71 | 72 | return { 73 | earliestTime, 74 | eventOrder, 75 | joins, 76 | latestTime, 77 | overloaded, 78 | parts 79 | }; 80 | } 81 | -------------------------------------------------------------------------------- /server/main/friends.js: -------------------------------------------------------------------------------- 1 | const async = require("async"); 2 | 3 | module.exports = function(db) { 4 | 5 | var currentFriendsList = []; 6 | var friendIdCache = {}; 7 | 8 | const getFriends = function(callback) { 9 | db.getFriends(callback); 10 | }; 11 | 12 | const getFriendsList = function(callback) { 13 | db.getFriendsList(callback); 14 | }; 15 | 16 | const loadFriendsList = function(callback) { 17 | getFriendsList((err, data) => { 18 | if (err) { 19 | if (typeof callback === "function") { 20 | callback(err); 21 | } 22 | } 23 | else { 24 | currentFriendsList = data; 25 | generateFriendIdCache(); 26 | if (typeof callback === "function") { 27 | callback(null, data); 28 | } 29 | } 30 | }); 31 | }; 32 | 33 | const generateFriendIdCache = function() { 34 | const friendIds = {}; 35 | getFriends((err, data) => { 36 | if (!err && data) { 37 | data.forEach((friend) => { 38 | if (friend && friend.friendId && friend.username) { 39 | friendIds[friend.username] = friend.friendId; 40 | } 41 | }); 42 | friendIdCache = friendIds; 43 | } 44 | }); 45 | }; 46 | 47 | const addToFriends = function(serverId, username, isBestFriend, callback) { 48 | db.addToFriends(serverId, username, isBestFriend, callback); 49 | }; 50 | 51 | // TODO: Use server name instead of server id here 52 | const modifyFriend = function(serverId, username, data, callback) { 53 | async.waterfall([ 54 | (callback) => db.getFriend(serverId, username, callback), 55 | (friend, callback) => db.modifyFriend(friend.friendId, data, callback) 56 | ], callback); 57 | }; 58 | 59 | const removeFromFriends = function(serverId, username, callback) { 60 | async.waterfall([ 61 | (callback) => db.getFriend(serverId, username, callback), 62 | (friend, callback) => db.removeFromFriends(friend.friendId, callback) 63 | ], callback); 64 | }; 65 | 66 | return { 67 | currentFriendsList: () => currentFriendsList, 68 | friendIdCache: () => friendIdCache, 69 | getFriends, 70 | getFriendsList, 71 | loadFriendsList, 72 | addToFriends, 73 | modifyFriend, 74 | removeFromFriends 75 | }; 76 | }; 77 | -------------------------------------------------------------------------------- /public/src/js/lib/conversations.js: -------------------------------------------------------------------------------- 1 | import actions from "../actions"; 2 | import store from "../store"; 3 | import { getChannelUri } from "./channelNames"; 4 | import { reportConversationAsSeen } from "./io"; 5 | 6 | function conversationKey(serverName, username) { 7 | return getChannelUri(serverName, username); 8 | } 9 | 10 | function setOpenConversationValue(key, diff) { 11 | let state = store.getState(); 12 | let { openConversations = {} } = state.viewState; 13 | let conversationValue = openConversations[key] || 0; 14 | store.dispatch(actions.viewState.update({ 15 | openConversations: { 16 | ...openConversations, 17 | [key]: conversationValue + diff 18 | } 19 | })); 20 | } 21 | 22 | export function setConversationAsOpen(serverName, username) { 23 | let key = conversationKey(serverName, username); 24 | setOpenConversationValue(key, 1); 25 | } 26 | 27 | export function setConversationAsClosed(serverName, username) { 28 | let key = conversationKey(serverName, username); 29 | setOpenConversationValue(key, -1); 30 | } 31 | 32 | export function reportConversationAsSeenIfNeeded(serverName, username) { 33 | let key = conversationKey(serverName, username); 34 | let state = store.getState(); 35 | let convo = state.unseenConversations[key]; 36 | 37 | if (convo) { 38 | reportConversationAsSeen(serverName, username); 39 | } 40 | } 41 | 42 | export function handleNewUnseenConversationsList(list) { 43 | // Do not include the ones open, if in focus, to prevent flashes 44 | 45 | if (list && Object.keys(list).length) { 46 | let state = store.getState(); 47 | let { inFocus } = state.deviceState; 48 | let { openConversations } = state.viewState; 49 | 50 | if (inFocus && openConversations) { 51 | list = { ...list }; 52 | Object.keys(list).forEach((key) => { 53 | if (openConversations[key] > 0) { 54 | let item = list[key]; 55 | 56 | // Report them as seen if they're open and we're in focus 57 | if (item) { 58 | let { serverName, username } = item; 59 | reportConversationAsSeen(serverName, username); 60 | } 61 | 62 | delete list[key]; 63 | } 64 | }); 65 | } 66 | } 67 | 68 | return list; 69 | } 70 | -------------------------------------------------------------------------------- /scripts/convertConfigToDb.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | const config = require("../config"); 4 | const dbSource = require("../server/db"); 5 | 6 | var db; 7 | 8 | dbSource({ setDb: (_) => { db = _; } }, () => { 9 | 10 | const callback = (err) => { 11 | if (err) { 12 | throw err; 13 | } 14 | }; 15 | 16 | var keyValues = _.omit(config, [ 17 | // Properties handled separately 18 | "irc", "nicknames", "friends", "bestFriends", 19 | // No longer a thing 20 | "debug", "encoding", "nonPeople" 21 | ]); 22 | 23 | // Key values 24 | _.forOwn(keyValues, (value, key) => { 25 | console.log("Adding key " + key); 26 | db.storeConfigValue(key, value, callback); 27 | }); 28 | 29 | // Nicknames 30 | if (config.nicknames && config.nicknames.length) { 31 | config.nicknames.forEach((nickname) => { 32 | console.log("Adding nickname " + nickname); 33 | db.addNickname(nickname, callback); 34 | }); 35 | } 36 | 37 | // Friends 38 | if (config.friends && config.friends.length) { 39 | config.friends.forEach((username) => { 40 | console.log("Adding friend " + username); 41 | db.addToFriends(0, username, false, callback); 42 | }); 43 | } 44 | if (config.bestFriends && config.bestFriends.length) { 45 | config.bestFriends.forEach((username) => { 46 | console.log("Adding best friend " + username); 47 | db.addToFriends(0, username, true, callback); 48 | }); 49 | } 50 | 51 | // IRC config 52 | if (config.irc && config.irc.length) { 53 | var serverId = 0; 54 | config.irc.forEach((server) => { 55 | serverId++; 56 | var serverData = _.omit(server, ["channels"]); 57 | serverData.hostname = serverData.hostname || serverData.server; 58 | serverData.nickname = serverData.nickname || serverData.username; 59 | console.log("Adding server " + server.name); 60 | db.addServerToIrcConfig(serverData, callback); 61 | 62 | if (server.channels && server.channels.length) { 63 | server.channels.forEach((channel) => { 64 | console.log("Adding channel " + channel); 65 | db.addChannelToIrcConfig( 66 | serverId, channel.replace(/^#/, ""), callback 67 | ); 68 | }); 69 | } 70 | }); 71 | } 72 | }); 73 | -------------------------------------------------------------------------------- /server/main/ircControl.js: -------------------------------------------------------------------------------- 1 | const { CHANNEL_TYPES } = require("../constants"); 2 | 3 | module.exports = function(irc, ircConfig, io) { 4 | const addAndJoinChannel = function(serverName, name, data, callback) { 5 | ircConfig.addChannelToIrcConfig( 6 | serverName, name, CHANNEL_TYPES.PUBLIC, data, 7 | (err) => { 8 | joinIrcChannel(serverName, name); 9 | if (io) { 10 | io.emitIrcConfig(); 11 | } 12 | if (typeof callback === "function") { 13 | callback(err); 14 | } 15 | } 16 | ); 17 | }; 18 | 19 | const connectUnconnectedIrcs = function() { 20 | irc.connectUnconnectedClients(); 21 | }; 22 | 23 | const loadAndConnectUnconnectedIrcs = function(callback) { 24 | ircConfig.loadAndEmitIrcConfig(() => { 25 | connectUnconnectedIrcs(); 26 | 27 | if (typeof callback === "function") { 28 | callback(); 29 | } 30 | }); 31 | }; 32 | 33 | const reconnectIrcServer = function(serverName) { 34 | irc.reconnectServer(serverName); 35 | }; 36 | 37 | const disconnectIrcServer = function(serverName) { 38 | irc.disconnectServer(serverName); 39 | }; 40 | 41 | const disconnectAndRemoveIrcServer = function(serverName) { 42 | irc.removeServer(serverName); 43 | }; 44 | 45 | const joinIrcChannel = function(serverName, channelName) { 46 | irc.joinChannel(serverName, channelName); 47 | }; 48 | 49 | const partIrcChannel = function(serverName, channelName) { 50 | irc.partChannel(serverName, channelName); 51 | }; 52 | 53 | const sendOutgoingMessage = function(channel, message, messageToken) { 54 | irc.sendOutgoingMessage(channel, message, messageToken); 55 | }; 56 | 57 | const currentIrcClients = function() { 58 | return irc.clients(); 59 | }; 60 | 61 | const getIrcChannelNameFromUri = function(channelUri) { 62 | return irc.getIrcChannelNameFromUri(channelUri); 63 | }; 64 | 65 | return { 66 | addAndJoinChannel, 67 | connectUnconnectedIrcs, 68 | currentIrcClients, 69 | disconnectAndRemoveIrcServer, 70 | disconnectIrcServer, 71 | getIrcChannelNameFromUri, 72 | joinIrcChannel, 73 | loadAndConnectUnconnectedIrcs, 74 | partIrcChannel, 75 | reconnectIrcServer, 76 | sendOutgoingMessage 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | 5 | const extractSass = new ExtractTextPlugin({ 6 | filename: "css/[name].css" 7 | }); 8 | 9 | const cwd = path.resolve("./"); 10 | const node_modules = path.resolve("./node_modules"); 11 | 12 | var config = {}; 13 | 14 | // Setup entry points 15 | config.entry = { 16 | "main": ["babel-polyfill", "./public/src/js/main.js"] 17 | }; 18 | 19 | // Setup output 20 | config.output = { 21 | filename: "js/[name].js", 22 | path: path.resolve("./public/dist"), 23 | publicPath: "/dist/" 24 | }; 25 | 26 | // Setup plugins 27 | config.plugins = []; 28 | 29 | // Setup rules 30 | config.module = { 31 | rules: [] 32 | }; 33 | 34 | // ESLint JS files 35 | config.module.rules.push({ 36 | test: /\.jsx?$/, 37 | loader: "eslint-loader", 38 | include: cwd, 39 | exclude: node_modules, 40 | enforce: "pre", 41 | options: { 42 | //configFile: "./eslintrc.js", 43 | emitError: true, 44 | emitWarning: true, 45 | failOnError: true 46 | } 47 | }); 48 | 49 | // Babel (and React) 50 | config.module.rules.push({ 51 | test: /\.jsx?$/, 52 | loader: "babel-loader", 53 | include: cwd, 54 | exclude: node_modules, 55 | options: { 56 | cacheDirectory: true, 57 | presets: [["es2015"/*, { modules: false }*/], "react"], 58 | plugins: [ 59 | "transform-object-rest-spread" 60 | ] 61 | } 62 | }); 63 | 64 | // Sass 65 | config.module.rules.push({ 66 | test: /\.scss$/, 67 | use: extractSass.extract({ 68 | use: [ 69 | { loader: "css-loader" }, 70 | { 71 | loader: "postcss-loader", 72 | options: { 73 | plugins: [ 74 | require("autoprefixer") 75 | ] 76 | } 77 | }, 78 | { loader: "sass-loader" } 79 | ] 80 | }) 81 | }); 82 | 83 | config.plugins.push(extractSass); 84 | 85 | // Set production/development related settings. 86 | config.plugins.push( 87 | new webpack.DefinePlugin({ 88 | __DEV__: "true", 89 | "process.env.NODE_ENV": JSON.stringify("development") 90 | }) 91 | ); 92 | 93 | // fixes for module and loader resolving 94 | config.resolve = { 95 | modules: [ node_modules, cwd ] 96 | }; 97 | 98 | module.exports = config; 99 | -------------------------------------------------------------------------------- /public/src/js/settingsview/SettingsFriendsView.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import forOwn from "lodash/forOwn"; 5 | 6 | import SettingsList from "./SettingsList.jsx"; 7 | import * as io from "../lib/io"; 8 | 9 | class SettingsFriendsView extends PureComponent { 10 | constructor(props) { 11 | super(props); 12 | 13 | this.renderLevelSelector = this.renderLevelSelector.bind(this); 14 | } 15 | 16 | handleAdd(friend) { 17 | console.log("Tried to add friend", friend); 18 | io.addNewFriend(friend.name, friend.level); 19 | } 20 | 21 | handleRemove(friend) { 22 | if (confirm(`Are you sure you want to remove ${friend.name} as a friend?`)) { 23 | console.log("Tried to remove friend", friend); 24 | io.removeFriend(friend.name); 25 | } 26 | } 27 | 28 | handleChangeLevel(friend, level) { 29 | console.log("Tried to change friend level", friend, level); 30 | io.changeFriendLevel(friend.name, level); 31 | } 32 | 33 | renderLevelSelector(friend) { 34 | const onChange = friend 35 | ? (evt) => this.handleChangeLevel(friend, evt.target.value) 36 | : null; 37 | return ( 38 | 42 | ); 43 | } 44 | 45 | render() { 46 | const { friendsList } = this.props; 47 | 48 | var allFriends = []; 49 | 50 | forOwn(friendsList, (list, level) => { 51 | allFriends = allFriends.concat( 52 | list.map((name) => ({ name, level: parseInt(level) })) 53 | ); 54 | }); 55 | 56 | allFriends.sort( 57 | (a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()) 58 | ); 59 | 60 | return ; 69 | } 70 | } 71 | 72 | SettingsFriendsView.propTypes = { 73 | friendsList: PropTypes.object 74 | }; 75 | 76 | export default connect(({ friendsList }) => ({ friendsList }))(SettingsFriendsView); 77 | -------------------------------------------------------------------------------- /public/src/js/lib/messageCaches.js: -------------------------------------------------------------------------------- 1 | import actions from "../actions"; 2 | import store from "../store"; 3 | import pull from "lodash/pull"; 4 | 5 | import { CACHE_LINES } from "../constants"; 6 | 7 | var cacheLinesSetting = CACHE_LINES; 8 | 9 | function handleNewCacheLinesSetting(value) { 10 | let fixedValue = parseInt(value, 10); 11 | 12 | if ( 13 | isNaN(fixedValue) || 14 | fixedValue < 20 || 15 | fixedValue > 500 16 | ) { 17 | fixedValue = CACHE_LINES; 18 | } 19 | 20 | if (fixedValue !== value) { 21 | store.dispatch(actions.appConfig.update({ cacheLines: fixedValue })); 22 | } 23 | 24 | if (fixedValue !== cacheLinesSetting) { 25 | cacheLinesSetting = fixedValue; 26 | } 27 | } 28 | 29 | export function cacheItem(cache, item, maxLines) { 30 | // Add it, or them 31 | if (item) { 32 | if (item instanceof Array) { 33 | cache = cache.concat(item); 34 | } else { 35 | cache = [ ...cache, item ]; 36 | } 37 | } 38 | 39 | // And make sure we only have the maximum amount 40 | if (cache.length > maxLines) { 41 | cache = cache.slice(cache.length - maxLines); 42 | } 43 | 44 | return cache; 45 | } 46 | 47 | export function cacheMessageItem(cache, item) { 48 | return cacheItem(cache, item, cacheLinesSetting); 49 | } 50 | 51 | export function clearReplacedIdsFromCache(cache, prevIds) { 52 | if (cache && cache.length && prevIds && prevIds.length) { 53 | const itemsWithPrevIds = cache.filter( 54 | (item) => prevIds.indexOf(item.lineId) >= 0 55 | ); 56 | return pull(cache, ...itemsWithPrevIds); 57 | 58 | // NOTE: We are modifiying in place to prevent too many change handlers from 59 | // occuring. We are expecting the caller to create a new array with an added 60 | // item immediately after having called this method. 61 | } 62 | 63 | return cache; 64 | } 65 | 66 | export function initializeMessageCaches() { 67 | // Look for new cache lines setting 68 | const importStateValue = function() { 69 | let state = store.getState(); 70 | let storedConfigValue = state.appConfig.cacheLines; 71 | 72 | if (storedConfigValue && storedConfigValue !== cacheLinesSetting) { 73 | handleNewCacheLinesSetting(storedConfigValue); 74 | } 75 | }; 76 | 77 | importStateValue(); 78 | store.subscribe(importStateValue); 79 | } 80 | -------------------------------------------------------------------------------- /serverplugins/twitch/emotes.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | const emoteParsing = require("./emoteParsing"); 4 | const twitchApiData = require("./twitchApiData"); 5 | const userStates = require("./userStates"); 6 | 7 | const USER_STATE_MESSAGE_FIELDS = [ 8 | "badges", "color", "display-name", "mod", "subscriber", "turbo", 9 | "user-id", "user-type" 10 | ]; 11 | 12 | const populateLocallyPostedTags = function(tags, serverName, channel, message) { 13 | if (tags) { 14 | let globalState = userStates.getGlobalUserState(serverName) || {}; 15 | let localState = userStates.getUserState(channel) || userStates.getAverageUserState(); 16 | 17 | _.assign( 18 | tags, 19 | _.pick(globalState, USER_STATE_MESSAGE_FIELDS), 20 | _.pick(localState, USER_STATE_MESSAGE_FIELDS), 21 | { 22 | emotes: emoteParsing.generateEmoticonIndices( 23 | message, 24 | twitchApiData.getEmoticonImages( 25 | localState["emote-sets"] || 26 | globalState["emote-sets"] 27 | ) 28 | ) 29 | } 30 | ); 31 | } 32 | }; 33 | 34 | const prepareEmotesInMessage = function(message, externalEmotes, cheerData) { 35 | let { 36 | channel, 37 | message: messageText, 38 | meUsername, 39 | postedLocally, 40 | serverName, 41 | tags, 42 | username 43 | } = message; 44 | 45 | if (tags.emotes) { 46 | // Parse emoticon indices supplied by Twitch 47 | if (typeof tags.emotes === "string") { 48 | tags.emotes = emoteParsing.parseEmoticonIndices( 49 | tags.emotes 50 | ); 51 | } 52 | } 53 | else if (postedLocally && username === meUsername) { 54 | // We posted this message, populate emotes 55 | populateLocallyPostedTags( 56 | tags, serverName, channel, messageText 57 | ); 58 | } 59 | else if ("emotes" in tags) { 60 | // Type normalization 61 | tags.emotes = []; 62 | } 63 | 64 | let emotes = tags.emotes || []; 65 | 66 | // Add cheers 67 | if (tags.bits) { 68 | let cheers = emoteParsing.generateCheerIndices( 69 | messageText, cheerData, emotes 70 | ); 71 | 72 | if (cheers) { 73 | tags.cheers = cheers; 74 | } 75 | } 76 | 77 | // Add external emotes 78 | tags.emotes = emoteParsing.generateEmoticonIndices( 79 | messageText, externalEmotes, emotes 80 | ); 81 | }; 82 | 83 | module.exports = { 84 | prepareEmotesInMessage 85 | }; 86 | -------------------------------------------------------------------------------- /public/src/scss/itemlist.scss: -------------------------------------------------------------------------------- 1 | 2 | .itemlist { 3 | list-style-type: none; 4 | padding: 0; 5 | margin: 10px -10px; 6 | 7 | li { 8 | padding: 3px 50px 3px 10px; 9 | position: relative; 10 | 11 | &.notime { 12 | padding-right: 10px; 13 | } 14 | 15 | &:not(.bestfriend) { 16 | /*font-size: 85%;*/ 17 | line-height: 1.6em; 18 | } 19 | 20 | &.bestfriend { 21 | font-size: 125%; 22 | padding-right: 60px; 23 | } 24 | 25 | &.online { 26 | strong { 27 | &:after { 28 | content: "●"; 29 | position: relative; 30 | display: inline-block; 31 | width: 6px; 32 | height: 6px; 33 | bottom: .125em; 34 | margin: 0 0 0 .25em; 35 | overflow: hidden; 36 | border-radius: 50%; 37 | color: #1b6; 38 | background-color: #1b6; 39 | } 40 | } 41 | } 42 | 43 | &.nothing { 44 | opacity: .5; 45 | text-align: center; 46 | padding: 20px 10px; 47 | } 48 | } 49 | 50 | a { 51 | color: inherit; 52 | } 53 | 54 | .anew { 55 | margin-top: 20px; 56 | } 57 | 58 | .subhead { 59 | margin: 20px 0 5px; 60 | /*font-size: 85%;*/ 61 | text-transform: uppercase; 62 | color: #999; 63 | 64 | h2 { 65 | font-size: 100%; 66 | margin: 0; 67 | line-height: 1em; 68 | } 69 | } 70 | 71 | .ts { 72 | position: absolute; 73 | top: 3px; 74 | right: 10px; 75 | text-align: right; 76 | } 77 | 78 | .l { 79 | white-space: nowrap; 80 | overflow: hidden; 81 | } 82 | 83 | time, .ts, .channel, .msg, .now { 84 | opacity: .5; 85 | 86 | a { 87 | font-weight: normal; 88 | } 89 | } 90 | 91 | .flash, .flash[style] { 92 | background-color: #c00 !important; 93 | color: #fff !important; 94 | 95 | strong { 96 | &:after { 97 | background-color: #fff !important; 98 | color: #fff !important; 99 | opacity: .5; 100 | } 101 | } 102 | } 103 | } 104 | 105 | .extralistitem { 106 | padding: 3px 10px; 107 | 108 | a { 109 | color: inherit !important; 110 | 111 | &.wide { 112 | font-weight: normal; 113 | 114 | &:hover { 115 | text-decoration: none; 116 | 117 | strong { 118 | text-decoration: underline; 119 | } 120 | } 121 | } 122 | } 123 | 124 | .now { 125 | opacity: .5; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /server/routes/home.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const async = require("async"); 3 | 4 | const configDefaults = require("../defaults"); 5 | const routeUtils = require("../util/routing"); 6 | 7 | module.exports = function(main) { 8 | return function(req, res) { 9 | const accepted = routeUtils.denyAccessWithoutToken(req, res, main); 10 | if (accepted) { 11 | let appConfig = main.appConfig(); 12 | let awakeTime = main.awakeTime; 13 | let friends = main.friends(); 14 | let ircConfig = main.ircConfig(); 15 | let ircConn = main.ircConnectionState; 16 | let lastSeen = main.lastSeen(); 17 | let nicknames = main.nicknames(); 18 | let serverData = main.serverData(); 19 | let unseenHighlights = main.unseenHighlights(); 20 | let unseenConversations = main.unseenConversations(); 21 | let userLists = main.userLists(); 22 | let viewState = main.viewState; 23 | 24 | async.parallel({ 25 | appConfig: appConfig.loadAppConfig, 26 | ircConfig: ircConfig.loadIrcConfig 27 | }, function(err, results) { 28 | if (err) { 29 | res.status(500); 30 | res.render("error", { 31 | appConfig: null, 32 | enableScripts: false, 33 | error: {}, 34 | message: err.message 35 | }); 36 | return; 37 | } 38 | 39 | let currentAppConfig = appConfig.safeAppConfig( 40 | _.assign({}, configDefaults, results.appConfig) 41 | ); 42 | 43 | res.render("index", { 44 | // Variables 45 | appConfig: currentAppConfig, 46 | awakeTime: awakeTime.toISOString(), 47 | friendsList: friends.currentFriendsList(), 48 | ircConfig: ircConfig.safeIrcConfigDict(results.ircConfig), 49 | ircConnectionState: ircConn.currentIrcConnectionState(), 50 | lastSeenChannels: lastSeen.lastSeenChannels(), 51 | lastSeenUsers: lastSeen.lastSeenUsers(), 52 | nicknames: nicknames.nicknamesDict(), 53 | onlineFriends: userLists.currentOnlineFriends(), 54 | serverData: serverData.getAllServerData(), 55 | token: routeUtils.getUsedToken(req), 56 | unseenConversations: unseenConversations.unseenConversations(), 57 | unseenHighlights: Array.from(unseenHighlights.unseenHighlightIds()), 58 | viewState: viewState.currentViewState(), 59 | // Template-related 60 | enableScripts: true 61 | }); 62 | }); 63 | } 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /public/src/js/twitch/TwitchChannelFlags.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import forOwn from "lodash/forOwn"; 4 | 5 | import { pluralize } from "../lib/formatting"; 6 | 7 | const TWITCH_CHANNEL_FLAGS_LABELS = { 8 | "emote-only": "emote", 9 | "followers-only": "follow", 10 | "r9k": "r9k", 11 | "slow": "slow", 12 | "subs-only": "sub" 13 | }; 14 | 15 | class TwitchChannelFlags extends PureComponent { 16 | 17 | render() { 18 | let { channelData = {} } = this.props; 19 | let { roomState } = channelData; 20 | 21 | if (roomState) { 22 | 23 | let flags = [], added = false; 24 | let roomId = roomState["room-id"]; 25 | 26 | forOwn(TWITCH_CHANNEL_FLAGS_LABELS, (label, prop) => { 27 | let value = parseInt(roomState[prop], 10); 28 | 29 | // Flip weird value for followers prop 30 | if (prop === "followers-only" && !isNaN(value)) { 31 | if (value === -1) { value = 0; } // Off 32 | else if (value === 0) { value = -1; } // On, no duration 33 | } 34 | 35 | // Parse and display value 36 | if (value && !isNaN(value)) { 37 | var tooltip; 38 | 39 | // Slow mode 40 | if (prop === "slow") { 41 | let seconds = pluralize(value, "second", "s"); 42 | tooltip = `${value} ${seconds} between messages`; 43 | } 44 | 45 | // Followers only mode 46 | else if (prop === "followers-only") { 47 | 48 | // Abort if no room id 49 | if (!roomId || roomId === "0") { 50 | return; 51 | } 52 | 53 | if (value > 0) { 54 | let minutes = pluralize(value, "minute", "s"); 55 | tooltip = `Chat after following for ${value} ${minutes}`; 56 | } 57 | else { 58 | tooltip = "Chat after following"; 59 | } 60 | } 61 | 62 | flags.push({ label, tooltip }); 63 | added = true; 64 | } 65 | }); 66 | 67 | if (added) { 68 | return ( 69 |
    70 | { flags.map(({ label, tooltip }) => ( 71 |
  • 74 | { label } 75 |
  • 76 | )) } 77 |
78 | ); 79 | } 80 | } 81 | 82 | return null; 83 | } 84 | } 85 | 86 | TwitchChannelFlags.propTypes = { 87 | channelData: PropTypes.object 88 | }; 89 | 90 | export default TwitchChannelFlags; 91 | -------------------------------------------------------------------------------- /public/src/js/lib/channelNames.js: -------------------------------------------------------------------------------- 1 | import { CHANNEL_TYPES } from "../constants"; 2 | 3 | const CHANNEL_URI_SEPARATOR = "/"; 4 | 5 | export function getChannelUri(server, channel, channelType = CHANNEL_TYPES.PUBLIC) { 6 | 7 | if (channelType === CHANNEL_TYPES.PRIVATE) { 8 | return getPrivateConversationUri(server, channel); 9 | } 10 | 11 | return server.replace(/\//g, "") + 12 | CHANNEL_URI_SEPARATOR + 13 | channel; 14 | } 15 | 16 | export function parseChannelUri(channelUri) { 17 | let separatorLocation = channelUri.indexOf(CHANNEL_URI_SEPARATOR); 18 | 19 | if (separatorLocation < 0) { 20 | return null; 21 | } 22 | 23 | let channelType = CHANNEL_TYPES.PUBLIC; 24 | 25 | if (channelUri.substr(0, 8) === "private:") { 26 | channelType = CHANNEL_TYPES.PRIVATE; 27 | channelUri = channelUri.substr(8); 28 | separatorLocation -= 8; 29 | } 30 | 31 | let server = channelUri.substr(0, separatorLocation); 32 | let channel = channelUri.substr( 33 | separatorLocation + CHANNEL_URI_SEPARATOR.length 34 | ); 35 | 36 | return { channel, channelType, server }; 37 | } 38 | 39 | export function channelNameFromUri(channelUri, prefix = "") { 40 | let uriData = parseChannelUri(channelUri); 41 | 42 | if (uriData && uriData.channel) { 43 | return prefix + uriData.channel; 44 | } 45 | 46 | return null; 47 | } 48 | 49 | export function serverNameFromChannelUri(channelUri) { 50 | let uriData = parseChannelUri(channelUri); 51 | 52 | if (uriData && uriData.server) { 53 | return uriData.server; 54 | } 55 | 56 | return null; 57 | } 58 | 59 | export function getPrivateConversationUri(serverName, username) { 60 | return "private:" + getChannelUri(serverName, username); 61 | } 62 | 63 | export function getChannelIrcConfigFromState(state, channelUri) { 64 | if (!channelUri) { 65 | return undefined; 66 | } 67 | 68 | let uriData = parseChannelUri(channelUri); 69 | 70 | if (!uriData) { 71 | return undefined; 72 | } 73 | 74 | let { channel, server } = uriData; 75 | 76 | let config = state.ircConfigs[server]; 77 | let setting = state.appConfig.enableTwitchChannelDisplayNames; 78 | 79 | if (setting && config) { 80 | let channelData = config.channels[channel]; 81 | return channelData; 82 | } 83 | } 84 | 85 | export function getChannelDisplayNameFromState(state, channelUri) { 86 | let channelData = getChannelIrcConfigFromState(state, channelUri); 87 | return channelData && channelData.displayName || ""; 88 | } 89 | -------------------------------------------------------------------------------- /public/src/js/settingsview/SettingsView.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { NavLink } from "react-router-dom"; 4 | 5 | import SettingsFriendsView from "./SettingsFriendsView.jsx"; 6 | import SettingsGeneralView from "./SettingsGeneralView.jsx"; 7 | import SettingsIrcView from "./SettingsIrcView.jsx"; 8 | import SettingsNicknamesView from "./SettingsNicknamesView.jsx"; 9 | import SettingsTwitchView from "./SettingsTwitchView.jsx"; 10 | import { SETTINGS_PAGE_NAMES } from "../constants"; 11 | import { settingsUrl } from "../lib/routeHelpers"; 12 | 13 | class SettingsView extends PureComponent { 14 | 15 | componentDidMount() { 16 | // Unlike all other main views, this view starts at the top 17 | window.scrollTo(0, 0); 18 | } 19 | 20 | render() { 21 | const { match: { params } } = this.props; 22 | var { pageName } = params; 23 | 24 | if (!pageName) { 25 | pageName = "general"; 26 | } 27 | 28 | var page; 29 | switch (pageName) { 30 | case "friends": 31 | page = ; 32 | break; 33 | case "irc": 34 | page = ; 35 | break; 36 | case "nicknames": 37 | page = ; 38 | break; 39 | case "twitch": 40 | page = ; 41 | break; 42 | default: 43 | page = ; 44 | } 45 | 46 | const items = Object.keys(SETTINGS_PAGE_NAMES); 47 | 48 | return ( 49 |
50 |
51 |
52 |

Settings

53 |
    54 | { items.map((item) => ( 55 |
  • 56 | 60 | { SETTINGS_PAGE_NAMES[item] } 61 | 62 |
  • 63 | )) } 64 |
65 |
66 |
67 |
68 |
69 |
70 | { page } 71 |
72 |
73 |
74 |
75 | ); 76 | } 77 | } 78 | 79 | SettingsView.propTypes = { 80 | match: PropTypes.object 81 | }; 82 | 83 | export default SettingsView; 84 | -------------------------------------------------------------------------------- /public/src/js/components/SidebarUserList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import forOwn from "lodash/forOwn"; 5 | 6 | import ChatViewLink from "./ChatViewLink.jsx"; 7 | import UserLink from "./UserLink.jsx"; 8 | import UserList from "./UserList.jsx"; 9 | import { CHANNEL_TYPES, PAGE_TYPES } from "../constants"; 10 | import { getChannelUri } from "../lib/channelNames"; 11 | import { pluralize, timeColors } from "../lib/formatting"; 12 | 13 | class SidebarUserList extends PureComponent { 14 | 15 | renderConvoItem(convo) { 16 | let { count, serverName, username, userDisplayName } = convo; 17 | let convoItemStyles = timeColors(0); 18 | let conversationUri = getChannelUri(serverName, username, CHANNEL_TYPES.PRIVATE); 19 | 20 | return ( 21 |
22 |
23 | 27 | 28 | 33 | 34 | {" "} 35 | { count } 36 | 37 | {" " + pluralize(count, "unread message", "s")} 38 | 39 | 40 |
41 |
42 | ); 43 | } 44 | 45 | render() { 46 | const { 47 | sort = "alpha", 48 | unseenConversations, 49 | visible = true 50 | } = this.props; 51 | 52 | let ignoreUsernames = [], unseenConvoEls = []; 53 | 54 | forOwn(unseenConversations, (convo, key) => { 55 | if (convo) { 56 | let { username } = convo; 57 | unseenConvoEls.push(this.renderConvoItem(convo, key)); 58 | ignoreUsernames.push(username); 59 | } 60 | }); 61 | 62 | return ( 63 |
64 | { unseenConvoEls } 65 | 70 |
71 | ); 72 | } 73 | } 74 | 75 | SidebarUserList.propTypes = { 76 | enableDarkMode: PropTypes.bool, 77 | sort: PropTypes.string, 78 | unseenConversations: PropTypes.object, 79 | visible: PropTypes.bool 80 | }; 81 | 82 | export default connect(({ 83 | appConfig: { enableDarkMode } 84 | }) => ({ 85 | enableDarkMode 86 | }))(SidebarUserList); 87 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const webpack = require("webpack"); 3 | const ExtractTextPlugin = require("extract-text-webpack-plugin"); 4 | 5 | const extractSass = new ExtractTextPlugin({ 6 | filename: "css/[name].css" 7 | }); 8 | 9 | const cwd = path.resolve("./"); 10 | const node_modules = path.resolve("./node_modules"); 11 | 12 | var config = {}; 13 | 14 | // Setup entry points 15 | config.entry = { 16 | "main": ["babel-polyfill", "./public/src/js/main.js"] 17 | }; 18 | 19 | // Setup output 20 | config.output = { 21 | filename: "js/[name].js", 22 | path: path.resolve("./public/dist"), 23 | publicPath: "/dist/" 24 | }; 25 | 26 | // Setup plugins 27 | config.plugins = []; 28 | 29 | config.plugins.push( 30 | new webpack.optimize.UglifyJsPlugin({ 31 | compress: { 32 | warnings: false 33 | } 34 | }) 35 | ); 36 | 37 | // Setup rules 38 | config.module = { 39 | rules: [] 40 | }; 41 | 42 | // ESLint JS files 43 | config.module.rules.push({ 44 | test: /\.jsx?$/, 45 | loader: "eslint-loader", 46 | include: cwd, 47 | exclude: node_modules, 48 | enforce: "pre", 49 | options: { 50 | //configFile: "./eslintrc.js", 51 | emitError: true, 52 | emitWarning: true, 53 | failOnError: true 54 | } 55 | }); 56 | 57 | // Babel (and React) 58 | config.module.rules.push({ 59 | test: /\.jsx?$/, 60 | loader: "babel-loader", 61 | include: cwd, 62 | exclude: node_modules, 63 | options: { 64 | cacheDirectory: true, 65 | presets: [["es2015"/*, { modules: false }*/], "react"], 66 | plugins: [ 67 | "transform-object-rest-spread" 68 | ] 69 | } 70 | }); 71 | 72 | // Sass 73 | config.module.rules.push({ 74 | test: /\.scss$/, 75 | use: extractSass.extract({ 76 | use: [ 77 | { loader: "css-loader", options: { minimize: true } }, 78 | { 79 | loader: "postcss-loader", 80 | options: { 81 | plugins: [ 82 | require("autoprefixer") 83 | ] 84 | } 85 | }, 86 | { loader: "sass-loader" } 87 | ] 88 | }) 89 | }); 90 | 91 | config.plugins.push(extractSass); 92 | 93 | // Set production/development related settings. 94 | config.plugins.push( 95 | new webpack.DefinePlugin({ 96 | __DEV__: "false", 97 | "process.env.NODE_ENV": JSON.stringify("production") 98 | }) 99 | ); 100 | 101 | // fixes for module and loader resolving 102 | config.resolve = { 103 | modules: [ node_modules, cwd ] 104 | }; 105 | 106 | module.exports = config; 107 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatUserListControl.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import { RELATIONSHIP_FRIEND, RELATIONSHIP_BEST_FRIEND } from "../constants"; 6 | import { storeViewState } from "../lib/io"; 7 | import store from "../store"; 8 | import actions from "../actions"; 9 | 10 | class ChatUserListControl extends PureComponent { 11 | constructor(props) { 12 | super(props); 13 | this.toggleUserList = this.toggleUserList.bind(this); 14 | } 15 | 16 | toggleUserList() { 17 | const { userListOpen } = this.props; 18 | const update = { userListOpen: !userListOpen }; 19 | store.dispatch(actions.viewState.update(update)); 20 | storeViewState(update); 21 | } 22 | 23 | render() { 24 | const { friendsList, userList } = this.props; 25 | 26 | var numUsers = 0, numFriends = 0; 27 | 28 | if (userList) { 29 | const usernames = Object.keys(userList); 30 | if (usernames && usernames.length) { 31 | numUsers = usernames.length; 32 | 33 | if (friendsList && friendsList[RELATIONSHIP_FRIEND]) { 34 | var allFriends = friendsList[RELATIONSHIP_FRIEND]; 35 | if (friendsList[RELATIONSHIP_BEST_FRIEND]) { 36 | allFriends = allFriends.concat( 37 | friendsList[RELATIONSHIP_BEST_FRIEND] 38 | ); 39 | } 40 | 41 | usernames.forEach((username) => { 42 | if (allFriends.indexOf(username.toLowerCase()) >= 0) { 43 | numFriends++; 44 | } 45 | }); 46 | } 47 | } 48 | } 49 | 50 | if (!numUsers) { 51 | return null; 52 | } 53 | 54 | const usersEl = numUsers + " user" + (numUsers === 1 ? "" : "s"); 55 | 56 | var friendsEl = null; 57 | 58 | if (numFriends > 0) { 59 | friendsEl = ( 60 | { " (" + 61 | numFriends + " friend" + 62 | (numFriends === 1 ? "" : "s") + 63 | ")" 64 | } 65 | ); 66 | } 67 | 68 | return ( 69 | 70 | { usersEl } 71 | { friendsEl } 72 | 73 | ); 74 | } 75 | } 76 | 77 | ChatUserListControl.propTypes = { 78 | channel: PropTypes.string, 79 | friendsList: PropTypes.object, 80 | userList: PropTypes.object, 81 | userListOpen: PropTypes.bool 82 | }; 83 | 84 | export default connect(({ 85 | channelUserLists, 86 | friendsList, 87 | viewState: { userListOpen } 88 | }, ownProps) => ({ 89 | friendsList, 90 | userList: channelUserLists[ownProps.channel], 91 | userListOpen 92 | }))(ChatUserListControl); 93 | -------------------------------------------------------------------------------- /scripts/setHttpsCerts.js: -------------------------------------------------------------------------------- 1 | const async = require("async"); 2 | const readline = require("readline"); 3 | 4 | const dbSource = require("../server/db"); 5 | 6 | var db; 7 | 8 | function askForPreference() { 9 | const rl = readline.createInterface({ 10 | input: process.stdin, 11 | output: process.stdout 12 | }); 13 | 14 | console.log("This is the script that helps you install/uninstall HTTPS certificates for your Pyramid server."); 15 | console.log(""); 16 | 17 | rl.question( 18 | "Do you wish to use certificates? [y/n]: ", 19 | (preference) => { 20 | rl.close(); 21 | process.stdout.write("\n"); 22 | handlePreference(preference); 23 | } 24 | ); 25 | } 26 | 27 | function handlePreference(preference) { 28 | preference = preference.toLowerCase(); 29 | var useSSL; 30 | 31 | if (preference === "y") { 32 | useSSL = true; 33 | } 34 | else if (preference === "n") { 35 | useSSL = false; 36 | } 37 | else { 38 | console.log("Please start over, and this time, type either y or n."); 39 | return; 40 | } 41 | 42 | if (useSSL) { 43 | askForFileNames(); 44 | } 45 | 46 | else { 47 | // Set both file names to "" 48 | console.log("Uninstalling certificates."); 49 | setFileNames("", ""); 50 | } 51 | } 52 | 53 | function askForFileNames() { 54 | const rl = readline.createInterface({ 55 | input: process.stdin, 56 | output: process.stdout 57 | }); 58 | 59 | console.log(""); 60 | console.log("Type the paths, relative to the Pyramid main folder, to your TLS key and certificate files. To make it really easy, you could put them in the main folder and then just type their file names here."); 61 | console.log(""); 62 | 63 | rl.question( 64 | "HTTPS key file path: ", 65 | (key) => { 66 | process.stdout.write("\n"); 67 | rl.question( 68 | "HTTPS cert file path: ", 69 | (cert) => { 70 | rl.close(); 71 | process.stdout.write("\n"); 72 | setFileNames(key, cert); 73 | } 74 | ); 75 | } 76 | ); 77 | } 78 | 79 | function setFileNames(key, cert) { 80 | async.parallel([ 81 | (callback) => db.storeConfigValue("httpsKeyPath", key, callback), 82 | (callback) => db.storeConfigValue("httpsCertPath", cert, callback) 83 | ], function(err) { 84 | if (err) { 85 | throw err; 86 | } 87 | else { 88 | console.log("Done. Restart your Pyramid instance if it's already running, and make sure to use either https or http depending on what you just chose."); 89 | } 90 | }); 91 | } 92 | 93 | // Init 94 | 95 | dbSource({ setDb: (_) => { db = _; } }, () => { 96 | askForPreference(); 97 | }); 98 | -------------------------------------------------------------------------------- /serverplugins/twitch/util.js: -------------------------------------------------------------------------------- 1 | const ASTRAL_SYMBOLS_REGEX = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g; 2 | 3 | var main; 4 | 5 | const isTwitch = function(client) { 6 | if (client && client.config) { 7 | return isTwitchHostname(client.config.hostname); 8 | } 9 | 10 | return false; 11 | }; 12 | 13 | const isTwitchHostname = function(hostname) { 14 | return /twitch\.tv$/.test(hostname); 15 | }; 16 | 17 | const channelIsGroupChat = function(channelName) { 18 | return channelName && channelName[0] === "_"; 19 | }; 20 | 21 | const rangesOverlap = function (x1, x2, y1, y2) { 22 | var low1, low2, high1, high2; 23 | if (x1 <= y1) { 24 | low1 = x1; 25 | low2 = x2; 26 | high1 = y1; 27 | high2 = y2; 28 | } 29 | else { 30 | low1 = y1; 31 | low2 = y2; 32 | high1 = x1; 33 | high2 = x2; 34 | } 35 | 36 | return low1 <= high2 && high1 <= low2; 37 | }; 38 | 39 | const stringWithoutAstralSymbols = function(str) { 40 | // Prevent index issues when astral symbols are involved in index-generating routine 41 | return str.replace(ASTRAL_SYMBOLS_REGEX, "_"); 42 | }; 43 | 44 | const getEnabledExternalEmoticonTypes = function(ffzEnabled, bttvEnabled) { 45 | const enabledTypes = []; 46 | 47 | if (ffzEnabled) { 48 | enabledTypes.push("ffz"); 49 | } 50 | 51 | if (bttvEnabled) { 52 | enabledTypes.push("bttv"); 53 | } 54 | 55 | return enabledTypes; 56 | }; 57 | 58 | const acceptRequest = function(callback) { 59 | return function(error, response, data) { 60 | if (!error) { 61 | if (response && response.statusCode === 200) { 62 | callback(null, data); 63 | } 64 | else { 65 | error = new Error(`Response status code ${response.statusCode}`); 66 | } 67 | } 68 | 69 | if (error) { 70 | callback(error); 71 | } 72 | }; 73 | }; 74 | 75 | const log = function() { 76 | if (main && main.log) { 77 | main.log.apply(null, arguments); 78 | } 79 | else { 80 | console.log.apply(console, arguments); 81 | } 82 | }; 83 | 84 | const warn = function() { 85 | if (main && main.warn) { 86 | main.warn.apply(null, arguments); 87 | } 88 | else { 89 | console.warn.apply(console, arguments); 90 | } 91 | }; 92 | 93 | const setMain = function(_main) { 94 | main = _main; 95 | }; 96 | 97 | module.exports = { 98 | acceptRequest, 99 | channelIsGroupChat, 100 | getEnabledExternalEmoticonTypes, 101 | isTwitch, 102 | isTwitchHostname, 103 | log, 104 | rangesOverlap, 105 | setMain, 106 | stringWithoutAstralSymbols, 107 | warn 108 | }; 109 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatUserEventLine.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import UserLink from "../../components/UserLink.jsx"; 5 | 6 | const block = "userevent"; 7 | 8 | class ChatUserEventLine extends PureComponent { 9 | render() { 10 | const { 11 | argument, 12 | by, 13 | displayName, 14 | displayUsername, 15 | message, 16 | mode, 17 | reason, 18 | server, 19 | type, 20 | username 21 | } = this.props; 22 | 23 | var eventDescription = ""; 24 | var isStrong = false; 25 | 26 | switch (type) { 27 | case "join": 28 | eventDescription = "joined"; 29 | break; 30 | case "part": 31 | eventDescription = "left" + (reason ? " (" + reason + ")" : ""); 32 | break; 33 | case "quit": 34 | eventDescription = "quit" + (reason ? " (" + reason + ")" : ""); 35 | break; 36 | case "kick": 37 | isStrong = true; 38 | eventDescription = "was kicked by " + by + 39 | (reason ? " (" + reason + ")" : ""); 40 | break; 41 | case "kill": 42 | eventDescription = "was killed" + 43 | (reason ? " (" + reason + ")" : ""); 44 | break; 45 | case "mode": 46 | eventDescription = "sets mode: " + mode + 47 | (argument ? " " + argument : ""); 48 | break; 49 | case "clearchat": 50 | eventDescription = message; 51 | break; 52 | } 53 | 54 | const className = block + 55 | (isStrong ? ` ${block}--strong` : ""); 56 | 57 | return ( 58 | 59 | { displayUsername 60 | ? ( 61 | 62 | 67 | {" "} 68 | 69 | ) : null } 70 | 71 | { eventDescription } 72 | 73 | 74 | ); 75 | } 76 | } 77 | 78 | ChatUserEventLine.propTypes = { 79 | argument: PropTypes.string, 80 | by: PropTypes.string, 81 | channel: PropTypes.string, 82 | channelName: PropTypes.string, 83 | displayChannel: PropTypes.bool, 84 | displayName: PropTypes.string, 85 | displayUsername: PropTypes.bool, 86 | highlight: PropTypes.array, 87 | lineId: PropTypes.string, 88 | message: PropTypes.string, 89 | mode: PropTypes.string, 90 | reason: PropTypes.string, 91 | server: PropTypes.string, 92 | time: PropTypes.string, 93 | type: PropTypes.string.isRequired, 94 | username: PropTypes.string.isRequired 95 | }; 96 | 97 | export default ChatUserEventLine; 98 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatViewWrapper.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ChatView from "./ChatView.jsx"; 5 | import NoChatView from "./NoChatView.jsx"; 6 | import { CATEGORY_NAMES } from "../constants"; 7 | import { getChannelUri, getPrivateConversationUri } from "../lib/channelNames"; 8 | import { getMyIrcNick } from "../lib/connectionStatus"; 9 | import { reportConversationAsSeenIfNeeded } from "../lib/conversations"; 10 | import { getChannelInfoByNames } from "../lib/ircConfigs"; 11 | import { parseLineIdHash } from "../lib/routeHelpers"; 12 | import { getUserInfo } from "../lib/users"; 13 | 14 | const VALID_CATEGORIES = Object.keys(CATEGORY_NAMES); 15 | 16 | class ChatViewWrapper extends PureComponent { 17 | render() { 18 | const { location, match: { params, url } } = this.props; 19 | 20 | const { 21 | categoryName, 22 | channelName, 23 | logDate, 24 | pageNumber, 25 | serverName, 26 | username 27 | } = params; 28 | 29 | var pageType, pageQuery; 30 | 31 | if ( 32 | channelName && serverName && 33 | getChannelInfoByNames(serverName, channelName) 34 | ) { 35 | pageType = "channel"; 36 | pageQuery = getChannelUri(serverName, channelName); 37 | } 38 | else if (username) { 39 | // Different behaviour dependent on the route 40 | let route = (url.match(/^\/([a-z]+)/) || [])[1]; 41 | 42 | if (route === "user" && getUserInfo(username)) { 43 | pageType = route; 44 | pageQuery = username; 45 | } 46 | 47 | else if (route === "conversation") { 48 | let myNick = getMyIrcNick(serverName); 49 | 50 | if (myNick && myNick !== username) { 51 | pageType = "channel"; 52 | pageQuery = getPrivateConversationUri( 53 | serverName, username 54 | ); 55 | reportConversationAsSeenIfNeeded(serverName, username); 56 | } 57 | } 58 | } 59 | else if ( 60 | categoryName && 61 | VALID_CATEGORIES.indexOf(categoryName) >= 0 62 | ) { 63 | pageType = "category"; 64 | pageQuery = categoryName; 65 | } 66 | 67 | if (!pageType) { 68 | return ; 69 | } 70 | 71 | const lineId = parseLineIdHash(location.hash); 72 | 73 | return ( 74 | 83 | ); 84 | } 85 | } 86 | 87 | ChatViewWrapper.propTypes = { 88 | location: PropTypes.object, 89 | match: PropTypes.object 90 | }; 91 | 92 | export default ChatViewWrapper; 93 | -------------------------------------------------------------------------------- /server/util/passwords.js: -------------------------------------------------------------------------------- 1 | const sodium = require("sodium"); 2 | 3 | const NOT_SO_SECRET_KEY = "Sup homie, I heard you like keys"; 4 | const KEY_LENGTH = 32; 5 | 6 | // Password hashing 7 | // Inspired by https://paragonie.com/blog/2016/02/how-safely-store-password-in-2016#nodejs 8 | 9 | function generatePasswordHash(password) { 10 | let hash = sodium.api.crypto_pwhash_str( 11 | new Buffer(password), 12 | sodium.api.crypto_pwhash_OPSLIMIT_INTERACTIVE, 13 | sodium.api.crypto_pwhash_MEMLIMIT_INTERACTIVE 14 | ); 15 | 16 | if (hash) { 17 | return hash.toString(); 18 | } 19 | 20 | return null; 21 | } 22 | 23 | function verifyPassword(enteredPassword, passwordHash) { 24 | var out; 25 | 26 | try { 27 | out = sodium.api.crypto_pwhash_str_verify( 28 | new Buffer(passwordHash), 29 | new Buffer(enteredPassword) 30 | ); 31 | } 32 | catch (e) { 33 | out = false; 34 | } 35 | 36 | return out; 37 | } 38 | 39 | // Symmetric encryption 40 | 41 | function convertTextToCompatibleKey(text) { 42 | // This is better than just repeating it or cutting it off (I think...) 43 | let m = new Buffer(text); 44 | let k = new Buffer(NOT_SO_SECRET_KEY); 45 | return sodium.api.crypto_generichash(KEY_LENGTH, m, k) 46 | .toString("base64"); 47 | } 48 | 49 | function packageSecret(secret) { 50 | 51 | if (!(typeof secret === "object")) { 52 | return null; 53 | } 54 | 55 | return { 56 | c: secret.cipherText.toString("base64"), 57 | n: secret.nonce.toString("base64") 58 | }; 59 | } 60 | 61 | function unpackageSecret(packagedSecret) { 62 | 63 | if (!(typeof packagedSecret === "object")) { 64 | return null; 65 | } 66 | 67 | // Expects an object with { cipherText, nonce } 68 | 69 | return { 70 | cipherText: new Buffer(packagedSecret.c, "base64"), 71 | nonce: new Buffer(packagedSecret.n, "base64") 72 | }; 73 | } 74 | 75 | function encryptSecret(secretMessage, keyString) { 76 | // Returns object { cipherText, nonce } 77 | 78 | let secretBox = new sodium.SecretBox( 79 | convertTextToCompatibleKey(keyString), "base64" 80 | ); 81 | 82 | return packageSecret(secretBox.encrypt(secretMessage, "utf8")); 83 | } 84 | 85 | function decryptSecret(packagedObj, keyString) { 86 | let encryptedObj = unpackageSecret(packagedObj); 87 | 88 | let secretBox = new sodium.SecretBox( 89 | convertTextToCompatibleKey(keyString), "base64" 90 | ); 91 | 92 | return secretBox.decrypt(encryptedObj, "utf8"); 93 | } 94 | 95 | module.exports = { 96 | decryptSecret, 97 | encryptSecret, 98 | generatePasswordHash, 99 | verifyPassword 100 | }; 101 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatViewLogBrowser.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import forOwn from "lodash/forOwn"; 4 | 5 | import ChatViewLink from "../components/ChatViewLink.jsx"; 6 | import { PAGE_TYPE_NAMES } from "../constants"; 7 | import { pluralize } from "../lib/formatting"; 8 | import { refElSetter } from "../lib/refEls"; 9 | 10 | class ChatViewLogBrowser extends PureComponent { 11 | constructor(props) { 12 | super(props); 13 | 14 | this.logBrowserSubmit = this.logBrowserSubmit.bind(this); 15 | 16 | this.els = {}; 17 | this.setLogRequestInput = refElSetter("logRequestInput").bind(this); 18 | } 19 | 20 | logBrowserSubmit(evt) { 21 | const { logUrl } = this.props; 22 | const { router } = this.context; 23 | const { logRequestInput } = this.els; 24 | 25 | if (evt) { 26 | evt.preventDefault(); 27 | } 28 | 29 | if (logRequestInput && logRequestInput.value && router && router.history) { 30 | router.history.push(logUrl(logRequestInput.value)); 31 | } 32 | } 33 | 34 | render() { 35 | const { logDate, logDetails, pageQuery, pageType } = this.props; 36 | 37 | var timeStamps = []; 38 | 39 | forOwn(logDetails, (hasLog, timeStamp) => { 40 | if (hasLog) { 41 | timeStamps.push(timeStamp); 42 | } 43 | }); 44 | 45 | timeStamps.sort(); 46 | 47 | const detailsEls = timeStamps.map((timeStamp) => { 48 | let className = timeStamp === logDate ? "current" : ""; 49 | let lines = logDetails[timeStamp]; 50 | let title = lines + " " + pluralize(lines, "line", "s"); 51 | 52 | return ( 53 |
  • 54 | 60 | { timeStamp } 61 | 62 |
  • 63 | ); 64 | }); 65 | 66 | return ( 67 |
    68 |
    70 |
    71 | {"Pick a date: "} 72 | 73 | {" "} 74 | 75 |
    76 |
    77 |
      78 | { detailsEls } 79 |
    80 |
    81 | ); 82 | } 83 | } 84 | 85 | ChatViewLogBrowser.propTypes = { 86 | logDate: PropTypes.string, 87 | logDetails: PropTypes.object, 88 | logUrl: PropTypes.func, 89 | pageQuery: PropTypes.string.isRequired, 90 | pageType: PropTypes.oneOf(PAGE_TYPE_NAMES).isRequired 91 | }; 92 | 93 | ChatViewLogBrowser.contextTypes = { 94 | router: PropTypes.object 95 | }; 96 | 97 | export default ChatViewLogBrowser; 98 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChatLines.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ChatLine from "./ChatLine.jsx"; 5 | import { humanDateStamp } from "../lib/formatting"; 6 | 7 | const block = "chatlines"; 8 | 9 | class ChatLines extends PureComponent { 10 | 11 | renderDateHeader(dateString) { 12 | if (!this.dateHeaderCache) { 13 | this.dateHeaderCache = {}; 14 | } 15 | 16 | let cachedEl = this.dateHeaderCache[dateString]; 17 | 18 | if (cachedEl) { 19 | return cachedEl; 20 | } 21 | 22 | let out = ( 23 |
  • 24 | { dateString } 25 |
  • 26 | ); 27 | 28 | this.dateHeaderCache[dateString] = out; 29 | 30 | return out; 31 | } 32 | 33 | render() { 34 | const { 35 | displayChannel, 36 | displayContextLink, 37 | displayFirstDate = true, 38 | displayUsername, 39 | loading, 40 | messages, 41 | observer, 42 | onEmoteLoad 43 | } = this.props; 44 | 45 | var content = null; 46 | 47 | if (messages && messages.length) { 48 | var lastDateString = ""; 49 | 50 | content = []; 51 | 52 | messages.forEach((msg, index) => { 53 | if (msg) { 54 | var dateString = humanDateStamp(new Date(msg.time), true, true); 55 | var line = ; 62 | 63 | // Detect date change 64 | if (dateString !== lastDateString) { 65 | 66 | // Insert date header 67 | if (displayFirstDate || lastDateString !== "") { 68 | lastDateString = dateString; 69 | content.push( 70 | this.renderDateHeader(dateString) 71 | ); 72 | } 73 | 74 | else { 75 | lastDateString = dateString; 76 | } 77 | } 78 | 79 | content.push(line); 80 | } 81 | }); 82 | } 83 | 84 | else if (!loading) { 85 | content =
  • Nothing here :(
  • ; 86 | } 87 | 88 | const className = block + 89 | (displayContextLink ? ` ${block}--with-context` : ""); 90 | 91 | return
      { content }
    ; 92 | } 93 | } 94 | 95 | ChatLines.propTypes = { 96 | displayChannel: PropTypes.bool, 97 | displayContextLink: PropTypes.bool, 98 | displayFirstDate: PropTypes.bool, 99 | displayUsername: PropTypes.bool, 100 | loading: PropTypes.bool, 101 | messages: PropTypes.array, 102 | observer: PropTypes.object, 103 | onEmoteLoad: PropTypes.func 104 | }; 105 | 106 | export default ChatLines; 107 | -------------------------------------------------------------------------------- /server/plugins.js: -------------------------------------------------------------------------------- 1 | // PYRAMID 2 | // Plugin interface 3 | 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | 7 | const PLUGINS_FOLDER = path.join(__dirname, "..", "serverplugins"); 8 | 9 | function pluginFolderPath(name) { 10 | return path.join(PLUGINS_FOLDER, name); 11 | } 12 | 13 | function pluginIndexPath(name) { 14 | return path.join(PLUGINS_FOLDER, name, "index.js"); 15 | } 16 | 17 | function getHandlerName(eventName) { 18 | return "on" + eventName[0].toUpperCase() + eventName.substr(1); 19 | } 20 | 21 | 22 | 23 | module.exports = function(main) { 24 | 25 | // State 26 | 27 | var pluginNames = []; 28 | var plugins = []; 29 | 30 | // Handler 31 | 32 | function handleEvent(name, data) { 33 | plugins.forEach((plugin) => { 34 | const handlerName = getHandlerName(name); 35 | if (plugin && typeof plugin[handlerName] === "function") { 36 | plugin[handlerName](data); 37 | } 38 | }); 39 | } 40 | 41 | // Handler for query events: Handlers that can stop the event from taking place 42 | 43 | function handleQueryEvent(name, data, callback) { 44 | for (var i = 0; i < plugins.length; i++) { 45 | let plugin = plugins[i]; 46 | const handlerName = getHandlerName(name); 47 | if (plugin && typeof plugin[handlerName] === "function") { 48 | let result = plugin[handlerName](data, callback); 49 | 50 | // Give it to the first plugin that gives a non-falsy response 51 | // It is this plugin's responsibility to either call the callback, 52 | // or intentionally omit it. 53 | 54 | // It is important that they only call the callback if they return 55 | // a non-falsy response, otherwise it will be called at least twice. 56 | 57 | if (result) { 58 | return; 59 | } 60 | } 61 | } 62 | 63 | // If we're here, no one handled it, so call the callback 64 | callback(); 65 | } 66 | 67 | // Start up 68 | 69 | let pluginFolderItems = fs.readdirSync(PLUGINS_FOLDER); 70 | pluginFolderItems.forEach((name) => { 71 | if (name && name[0] !== ".") { 72 | let stat = fs.statSync(pluginFolderPath(name)); 73 | if (stat && stat.isDirectory()) { 74 | let indexPath = pluginIndexPath(name); 75 | try { 76 | fs.accessSync(indexPath, fs.constants.R_OK); 77 | pluginNames.push(name); 78 | } 79 | catch (e) { 80 | // Inaccessible 81 | } 82 | } 83 | } 84 | }); 85 | 86 | plugins = pluginNames.map((name) => { 87 | var plugin = require(pluginIndexPath(name)); 88 | 89 | if (typeof plugin === "function") { 90 | plugin = plugin(main); 91 | } 92 | 93 | console.log("Loaded plugin: " + name); 94 | 95 | return plugin; 96 | }); 97 | 98 | const output = { handleEvent, handleQueryEvent }; 99 | 100 | main.setPlugins(output); 101 | 102 | return output; 103 | }; 104 | -------------------------------------------------------------------------------- /public/src/js/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const appConfig = { 2 | "UPDATE": "appConfig/UPDATE" 3 | }; 4 | 5 | export const categoryCaches = { 6 | "UPDATE": "categoryCaches/UPDATE", 7 | "APPEND": "categoryCaches/APPEND" 8 | }; 9 | 10 | export const channelCaches = { 11 | "UPDATE": "channelCaches/UPDATE", 12 | "APPEND": "channelCaches/APPEND", 13 | "REMOVE": "channelCaches/REMOVE", 14 | "CLEARUSER": "channelCaches/CLEARUSER", 15 | "STARTEXPIRATION": "channelCaches/STARTEXPIRATION", 16 | "STOPEXPIRATION": "channelCaches/STOPEXPIRATION" 17 | }; 18 | 19 | export const channelData = { 20 | "UPDATE": "channelData/UPDATE" 21 | }; 22 | 23 | export const channelUserLists = { 24 | "UPDATE": "channelUserLists/UPDATE" 25 | }; 26 | 27 | export const connectionStatus = { 28 | "UPDATE": "connectionStatus/UPDATE" 29 | }; 30 | 31 | export const deviceState = { 32 | "UPDATE": "deviceState/UPDATE" 33 | }; 34 | 35 | export const friendsList = { 36 | "SET": "friendsList/SET", 37 | "UPDATE": "friendsList/UPDATE" 38 | }; 39 | 40 | export const ircConfigs = { 41 | "SET": "ircConfigs/SET", 42 | "UPDATE": "ircConfigs/UPDATE" 43 | }; 44 | 45 | export const lineInfo = { 46 | "CLEAR": "lineInfo/CLEAR", 47 | "UPDATE": "lineInfo/UPDATE" 48 | }; 49 | 50 | export const logDetails = { 51 | "UPDATE": "logDetails/UPDATE" 52 | }; 53 | 54 | export const logFiles = { 55 | "CLEAR": "logFiles/CLEAR", 56 | "UPDATE": "logFiles/UPDATE" 57 | }; 58 | 59 | export const lastSeenChannels = { 60 | "UPDATE": "lastSeenChannels/UPDATE" 61 | }; 62 | 63 | export const lastSeenUsers = { 64 | "UPDATE": "lastSeenUsers/UPDATE" 65 | }; 66 | 67 | export const multiServerChannels = { 68 | "SET": "multiServerChannels/SET" 69 | }; 70 | 71 | export const nicknames = { 72 | "SET": "nicknames/SET", 73 | "UPDATE": "nicknames/UPDATE" 74 | }; 75 | 76 | export const onlineFriends = { 77 | "SET": "onlineFriends/SET" 78 | }; 79 | 80 | export const offlineMessages = { 81 | "ADD": "offlineMessages/ADD", 82 | "REMOVE": "offlineMessages/REMOVE" 83 | }; 84 | 85 | export const serverData = { 86 | "SET": "serverData/SET", 87 | "UPDATE": "serverData/UPDATE" 88 | }; 89 | 90 | export const systemInfo = { 91 | "UPDATE": "systemInfo/UPDATE" 92 | }; 93 | 94 | export const token = { 95 | "SET": "token/SET" 96 | }; 97 | 98 | export const unseenConversations = { 99 | "SET": "unseenConversations/SET" 100 | }; 101 | 102 | export const unseenHighlights = { 103 | "SET": "unseenHighlights/SET" 104 | }; 105 | 106 | export const userCaches = { 107 | "UPDATE": "userCaches/UPDATE", 108 | "APPEND": "userCaches/APPEND", 109 | "REMOVE": "userCaches/REMOVE", 110 | "STARTEXPIRATION": "userCaches/STARTEXPIRATION", 111 | "STOPEXPIRATION": "userCaches/STOPEXPIRATION" 112 | }; 113 | 114 | export const viewState = { 115 | "UPDATE": "viewState/UPDATE" 116 | }; 117 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatHighlightedLine.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { findDOMNode } from "react-dom"; 4 | import { connect } from "react-redux"; 5 | 6 | class ChatHighlightedLine extends PureComponent { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.onUnobserved = this.onUnobserved.bind(this); 11 | } 12 | 13 | componentDidMount () { 14 | this.toggleObservation( 15 | this.isUnseen(this.props), 16 | false 17 | ); 18 | } 19 | 20 | componentDidUpdate(prevProps) { 21 | this.toggleObservation( 22 | this.isUnseen(this.props), 23 | this.isUnseen(prevProps) 24 | ); 25 | } 26 | 27 | componentWillUnmount() { 28 | if (this.observed) { 29 | this.unobserve(); 30 | } 31 | } 32 | 33 | isUnseen(props = this.props) { 34 | const { lineId, unseenHighlights } = props; 35 | 36 | return lineId && 37 | unseenHighlights && 38 | unseenHighlights.length && 39 | unseenHighlights.indexOf(lineId) >= 0; 40 | } 41 | 42 | observe() { 43 | const { lineId, observer } = this.props; 44 | 45 | if (observer) { 46 | const root = findDOMNode(this); 47 | 48 | if (root) { 49 | // Adding extra info to root 50 | root.onUnobserve = this.onUnobserved; 51 | root.lineId = lineId; 52 | 53 | // Setting root 54 | this.root = root; 55 | 56 | // Observing 57 | observer.observe(root); 58 | this.observed = true; 59 | } 60 | } 61 | else { 62 | console.warn( 63 | "Tried to observe in HighlightObserver, " + 64 | "but no observer was supplied" 65 | ); 66 | } 67 | } 68 | 69 | toggleObservation(onNow, onThen) { 70 | if (onNow !== onThen) { 71 | if (onNow) { 72 | this.observe(); 73 | } 74 | else { 75 | this.unobserve(); 76 | } 77 | } 78 | } 79 | 80 | unobserve() { 81 | const { observer } = this.props; 82 | 83 | if (this.root && observer) { 84 | observer.unobserve(this.root); 85 | this.onUnobserved(); 86 | } 87 | } 88 | 89 | onUnobserved() { 90 | this.observed = false; 91 | } 92 | 93 | render() { 94 | const { children, className: givenClassName, id } = this.props; 95 | const unseen = this.isUnseen(this.props); 96 | 97 | const itemProps = { 98 | className: givenClassName + 99 | (unseen ? " line--unseen-highlight" : ""), 100 | id 101 | }; 102 | return
  • { children }
  • ; 103 | } 104 | } 105 | 106 | ChatHighlightedLine.propTypes = { 107 | className: PropTypes.string, 108 | children: PropTypes.node.isRequired, 109 | id: PropTypes.string, 110 | lineId: PropTypes.string.isRequired, 111 | observer: PropTypes.object.isRequired, 112 | unseenHighlights: PropTypes.array 113 | }; 114 | 115 | export default connect( 116 | ({ unseenHighlights }) => ({ unseenHighlights }) 117 | )(ChatHighlightedLine); 118 | -------------------------------------------------------------------------------- /public/src/js/components/UserLink.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import ChatViewLink from "./ChatViewLink.jsx"; 6 | import { CHANNEL_TYPES } from "../constants"; 7 | import { getChannelUri } from "../lib/channelNames"; 8 | import { getMyIrcNick } from "../lib/connectionStatus"; 9 | import { getTwitchUserDisplayNameData } from "../lib/displayNames"; 10 | 11 | const block = "userlink"; 12 | 13 | class UserLink extends PureComponent { 14 | render() { 15 | const { 16 | displayName, 17 | enableTwitchUserDisplayNames, 18 | friendsList, 19 | noLink, 20 | serverName, 21 | username 22 | } = this.props; 23 | 24 | if (!username) { 25 | return null; 26 | } 27 | 28 | var content = username; 29 | 30 | // If displaying display name 31 | let displayNameData = getTwitchUserDisplayNameData( 32 | username, displayName, enableTwitchUserDisplayNames 33 | ); 34 | 35 | let { primary, secondary, tooltip } = displayNameData; 36 | 37 | if (secondary) { 38 | content = [ 39 | primary + " ", 40 | ({ secondary }) 41 | ]; 42 | } 43 | else { 44 | content = primary; 45 | } 46 | 47 | // Does this user exist in the friends list? 48 | 49 | var isFriend = false; 50 | 51 | for (var list in friendsList) { 52 | if (friendsList.hasOwnProperty(list)) { 53 | if (friendsList[list].indexOf(username) >= 0) { 54 | isFriend = true; 55 | break; 56 | } 57 | } 58 | } 59 | 60 | var type, query; 61 | 62 | if (!isFriend || noLink) { 63 | 64 | // Link-free output if wanted 65 | 66 | let me = serverName && getMyIrcNick(serverName); 67 | 68 | if (noLink || !serverName || username === me) { 69 | return { content }; 70 | } 71 | 72 | // Conversation link output for non-friends 73 | 74 | type = "channel"; 75 | query = getChannelUri(serverName, username, CHANNEL_TYPES.PRIVATE); 76 | } 77 | 78 | else { 79 | type = "user"; 80 | query = username; 81 | } 82 | 83 | // User page link output for friends 84 | 85 | return ( 86 | 92 | { content } 93 | 94 | ); 95 | } 96 | } 97 | 98 | UserLink.propTypes = { 99 | displayName: PropTypes.string, 100 | enableTwitchUserDisplayNames: PropTypes.number, 101 | friendsList: PropTypes.object, 102 | noLink: PropTypes.bool, 103 | serverName: PropTypes.string, 104 | username: PropTypes.string.isRequired 105 | }; 106 | 107 | export default connect(({ 108 | appConfig: { enableTwitchUserDisplayNames }, 109 | friendsList 110 | }) => ({ 111 | enableTwitchUserDisplayNames, 112 | friendsList 113 | }))(UserLink); 114 | -------------------------------------------------------------------------------- /public/src/js/lib/visualBehavior.js: -------------------------------------------------------------------------------- 1 | import actions from "../actions"; 2 | import store from "../store"; 3 | 4 | export function isMobile() { 5 | return window.innerWidth < 768; 6 | } 7 | 8 | function initTouchDeviceTest() { 9 | window.addEventListener("touchstart", function onFirstTouch() { 10 | store.dispatch(actions.deviceState.update({ isTouchDevice: true })); 11 | window.removeEventListener("touchstart", onFirstTouch, false); 12 | }, false); 13 | } 14 | 15 | export function initVisualBehavior() { 16 | initTouchDeviceTest(); 17 | initFocusHandler(); 18 | patchSFFontIssues(); 19 | } 20 | 21 | function getFocus() { 22 | var inFocus; 23 | 24 | try { 25 | inFocus = document.hasFocus(); 26 | } catch(e){} // eslint-disable-line no-empty 27 | 28 | if (typeof inFocus !== "boolean") { 29 | // Assume we are 30 | return true; 31 | } 32 | 33 | return inFocus; 34 | } 35 | 36 | function getVisibility() { 37 | let visible = !document.hidden; 38 | 39 | if (typeof visible !== "boolean") { 40 | // Assume it is 41 | return true; 42 | } 43 | 44 | return visible; 45 | } 46 | 47 | function setFocus(inFocus) { 48 | let state = store.getState(); 49 | let changes = {}; 50 | let changed = false; 51 | let visible = getVisibility(); 52 | 53 | if (state && state.deviceState.inFocus !== inFocus) { 54 | changes.inFocus = inFocus; 55 | changed = true; 56 | } 57 | 58 | if (state && state.deviceState.visible !== visible) { 59 | changes.visible = visible; 60 | changed = true; 61 | } 62 | 63 | if (changed) { 64 | store.dispatch(actions.deviceState.update(changes)); 65 | } 66 | } 67 | 68 | function visibilityChangeHandler() { 69 | setFocus(getFocus()); 70 | } 71 | 72 | function focusHandler() { 73 | setFocus(true); 74 | } 75 | 76 | function blurHandler() { 77 | setFocus(false); 78 | } 79 | 80 | function initFocusHandler() { 81 | visibilityChangeHandler(); 82 | window.addEventListener("visibilitychange", visibilityChangeHandler); 83 | window.addEventListener("focus", focusHandler); 84 | window.addEventListener("blur", blurHandler); 85 | } 86 | 87 | export function setDarkModeStatus(status) { 88 | let list = document.body.classList; 89 | let name = "darkmode"; 90 | if (status && !list.contains(name)) { 91 | list.add(name); 92 | } 93 | else if (!status && list.contains(name)) { 94 | list.remove(name); 95 | } 96 | } 97 | 98 | function patchSFFontIssues() { 99 | let ua = navigator.userAgent; 100 | let osMatch = ua.match(/Mac OS X 10_([0-9]+)/); 101 | let osVersion = osMatch && osMatch[1] && parseInt(osMatch[1], 10); 102 | 103 | if (osVersion && osVersion >= 11) { 104 | // >= macOS 10.11, we assume SF font 105 | 106 | // General 107 | document.body.classList.add("sf-font"); 108 | 109 | // Issue with chrome 110 | if (/Chrome\//.test(ua)) { 111 | document.body.classList.add("chrome-sf-font"); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /public/src/js/chatview/chatline/ChatOfflineResendButton.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import { GLOBAL_CONNECTION, STATUS } from "../../lib/connectionStatus"; 6 | import { resendOfflineMessage } from "../../lib/posting"; 7 | 8 | // How many ms before the button becomes visible 9 | const VISIBILITY_TIME_MS = 5000; 10 | 11 | const block = "line__offline-resend"; 12 | 13 | class ChatOfflineResendButton extends PureComponent { 14 | constructor(props) { 15 | super(props); 16 | 17 | this.resend = this.resend.bind(this); 18 | this.show = this.show.bind(this); 19 | 20 | let visible = this.prepareVisibility(props); 21 | 22 | this.state = { clicked: false, visible }; 23 | } 24 | 25 | componentWillReceiveProps(newProps) { 26 | if (newProps.time !== this.props.time) { 27 | let visible = this.prepareVisibility(newProps); 28 | if (visible !== this.state.visible) { 29 | this.setState({ clicked: false, visible }); 30 | } 31 | } 32 | } 33 | 34 | componentWillUnmount() { 35 | if (this.timeoutId) { 36 | clearTimeout(this.timeoutId); 37 | this.timeoutId = null; 38 | } 39 | } 40 | 41 | prepareVisibility(props = this.props) { 42 | let visibleTime = new Date(props.time).valueOf() + VISIBILITY_TIME_MS; 43 | let timeUntilVisible = visibleTime - new Date(); 44 | let visible = timeUntilVisible < 200; 45 | 46 | if (!visible) { 47 | if (this.timeoutId) { 48 | clearTimeout(this.timeoutId); 49 | this.timeoutId = null; 50 | } 51 | 52 | this.timeoutId = setTimeout(this.show, timeUntilVisible); 53 | } 54 | 55 | return visible; 56 | } 57 | 58 | resend() { 59 | let { channel, messageToken } = this.props; 60 | resendOfflineMessage(channel, messageToken); 61 | this.setState({ clicked: true }); 62 | } 63 | 64 | show() { 65 | this.setState({ clicked: false, visible: true }); 66 | } 67 | 68 | render() { 69 | let { globalConnectionStatus } = this.props; 70 | let { clicked, visible } = this.state; 71 | 72 | if ( 73 | !clicked && 74 | visible && 75 | globalConnectionStatus && 76 | globalConnectionStatus.status === STATUS.CONNECTED 77 | ) { 78 | return ( 79 | 84 | Resend 85 | 86 | ); 87 | } 88 | 89 | return null; 90 | } 91 | } 92 | 93 | ChatOfflineResendButton.propTypes = { 94 | channel: PropTypes.string.isRequired, 95 | globalConnectionStatus: PropTypes.object, 96 | messageToken: PropTypes.string.isRequired, 97 | time: PropTypes.string.isRequired 98 | }; 99 | 100 | export default connect(({ 101 | connectionStatus 102 | }) => ({ 103 | globalConnectionStatus: connectionStatus[GLOBAL_CONNECTION] 104 | }))(ChatOfflineResendButton); 105 | -------------------------------------------------------------------------------- /server/util/channels.js: -------------------------------------------------------------------------------- 1 | const { CHANNEL_TYPES } = require("../constants"); 2 | 3 | const CHANNEL_URI_SEPARATOR = "/"; 4 | 5 | const getChannelUri = function(server, channel, channelType = CHANNEL_TYPES.PUBLIC) { 6 | 7 | if (channelType === CHANNEL_TYPES.PRIVATE) { 8 | return getPrivateConversationUri(server, channel); 9 | } 10 | 11 | return server.replace(/\//g, "") + 12 | CHANNEL_URI_SEPARATOR + 13 | channel.replace(/^#/, ""); 14 | 15 | // TODO: Stripping the # character should happen another place, to prevent it from happening more than once 16 | }; 17 | 18 | const parseChannelUri = function(channelUri) { 19 | let separatorLocation = channelUri.indexOf(CHANNEL_URI_SEPARATOR); 20 | 21 | if (separatorLocation < 0) { 22 | return null; 23 | } 24 | 25 | let channelType = CHANNEL_TYPES.PUBLIC; 26 | 27 | if (channelUri.substr(0, 8) === "private:") { 28 | channelType = CHANNEL_TYPES.PRIVATE; 29 | channelUri = channelUri.substr(8); 30 | separatorLocation -= 8; 31 | } 32 | 33 | let server = channelUri.substr(0, separatorLocation); 34 | let channel = channelUri.substr( 35 | separatorLocation + CHANNEL_URI_SEPARATOR.length 36 | ); 37 | 38 | return { channel, channelType, server }; 39 | }; 40 | 41 | const channelNameFromUri = function(channelUri, prefix = "") { 42 | let uriData = parseChannelUri(channelUri); 43 | 44 | if (uriData && uriData.channel) { 45 | return prefix + uriData.channel; 46 | } 47 | 48 | return null; 49 | }; 50 | 51 | const serverNameFromChannelUri = function(channelUri) { 52 | let uriData = parseChannelUri(channelUri); 53 | 54 | if (uriData && uriData.server) { 55 | return uriData.server; 56 | } 57 | 58 | return null; 59 | }; 60 | 61 | const getPrivateConversationUri = function(serverName, username) { 62 | return "private:" + getChannelUri(serverName, username); 63 | }; 64 | 65 | const passesChannelWhiteBlacklist = function(target, channelUri) { 66 | const uriData = parseChannelUri(channelUri); 67 | 68 | if (uriData && target) { 69 | let { channel, server } = uriData; 70 | 71 | // If there is a white list, and we're not on it, return false 72 | if ( 73 | target.channelWhitelist && 74 | target.channelWhitelist.length && 75 | target.channelWhitelist.indexOf(channel) < 0 76 | ) { 77 | return false; 78 | } 79 | 80 | // If we're on the blacklist, return false 81 | if ( 82 | target.channelBlacklist && 83 | target.channelBlacklist.indexOf(channel) >= 0 84 | ) { 85 | return false; 86 | } 87 | 88 | // Same for servers 89 | 90 | if ( 91 | target.serverWhitelist && 92 | target.serverWhitelist.length && 93 | target.serverWhitelist.indexOf(server) < 0 94 | ) { 95 | return false; 96 | } 97 | 98 | if ( 99 | target.serverBlacklist && 100 | target.serverBlacklist.indexOf(server) >= 0 101 | ) { 102 | return false; 103 | } 104 | } 105 | 106 | return true; 107 | }; 108 | 109 | module.exports = { 110 | channelNameFromUri, 111 | serverNameFromChannelUri, 112 | getChannelUri, 113 | getPrivateConversationUri, 114 | parseChannelUri, 115 | passesChannelWhiteBlacklist 116 | }; 117 | -------------------------------------------------------------------------------- /public/src/js/components/TimedUserItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | 5 | import ChannelLink from "./ChannelLink.jsx"; 6 | import TimedItem from "./TimedItem.jsx"; 7 | import UserLink from "./UserLink.jsx"; 8 | import { RELATIONSHIP_BEST_FRIEND } from "../constants"; 9 | import { getChannelDisplayNameFromState } from "../lib/channelNames"; 10 | 11 | class TimedUserItem extends PureComponent { 12 | 13 | render() { 14 | const { 15 | channelDisplayName, 16 | contextChannel, 17 | displayOnline = false, 18 | friendsList = {}, 19 | lastSeenData = {}, 20 | onlineFriends = [], 21 | skipOld = true, 22 | symbol = "", 23 | username, 24 | visible 25 | } = this.props; 26 | 27 | let { displayName, time } = lastSeenData; 28 | 29 | var classNames = []; 30 | 31 | if ( 32 | friendsList[RELATIONSHIP_BEST_FRIEND] && 33 | friendsList[RELATIONSHIP_BEST_FRIEND] 34 | .indexOf(username.toLowerCase()) >= 0 35 | ) { 36 | classNames.push("bestfriend"); 37 | } 38 | 39 | if ( 40 | displayOnline && 41 | onlineFriends.indexOf(username.toLowerCase()) >= 0 42 | ) { 43 | classNames.push("online"); 44 | } 45 | 46 | var className = classNames.join(" "); 47 | 48 | const prefix = ( 49 | 50 | { symbol } 51 | 55 | 56 | ); 57 | 58 | var suffix = null; 59 | 60 | if (lastSeenData && lastSeenData.channel) { 61 | let { channel, channelName } = lastSeenData; 62 | const channelEl = contextChannel === channel 63 | ? "here" 64 | : [ 65 | "in ", 66 | 73 | ]; 74 | 75 | suffix = { channelEl }; 76 | } 77 | 78 | return ; 87 | } 88 | } 89 | 90 | TimedUserItem.propTypes = { 91 | channelDisplayName: PropTypes.string, 92 | contextChannel: PropTypes.string, 93 | displayOnline: PropTypes.bool, 94 | friendsList: PropTypes.object, 95 | lastSeenData: PropTypes.object, 96 | onlineFriends: PropTypes.array, 97 | skipOld: PropTypes.bool, 98 | symbol: PropTypes.string, 99 | username: PropTypes.string, 100 | visible: PropTypes.bool 101 | }; 102 | 103 | const mapStateToProps = function(state, ownProps) { 104 | let { lastSeenData = {} } = ownProps; 105 | let { channel } = lastSeenData; 106 | let { friendsList, onlineFriends } = state; 107 | 108 | let channelDisplayName = getChannelDisplayNameFromState(state, channel); 109 | 110 | return { 111 | channelDisplayName, 112 | friendsList, 113 | onlineFriends 114 | }; 115 | }; 116 | 117 | export default connect(mapStateToProps)(TimedUserItem); 118 | -------------------------------------------------------------------------------- /public/src/js/reducers/channelCaches.js: -------------------------------------------------------------------------------- 1 | import clone from "lodash/clone"; 2 | import omit from "lodash/omit"; 3 | 4 | import * as actionTypes from "../actionTypes"; 5 | import { startExpiringChannelCache } from "../lib/dataExpiration"; 6 | import { cacheMessageItem, clearReplacedIdsFromCache } from "../lib/messageCaches"; 7 | 8 | const channelCachesInitialState = {}; 9 | 10 | function clearUserHandler(username, time) { 11 | let d = new Date(time); 12 | return function(item) { 13 | if ( 14 | // By this user... 15 | item.username === username && 16 | // Not already cleared... 17 | !item.cleared && 18 | // Messages only... 19 | ( 20 | item.type === "msg" || 21 | item.type === "action" || 22 | item.type === "notice" 23 | ) && 24 | // With timestamps... 25 | item.time && 26 | // ...that are before the ban time. 27 | new Date(item.time) <= d 28 | ) { 29 | // The message is cleared! 30 | return { ...item, cleared: true }; 31 | } 32 | 33 | return item; 34 | }; 35 | } 36 | 37 | export default function (state = channelCachesInitialState, action) { 38 | 39 | switch (action.type) { 40 | case actionTypes.channelCaches.UPDATE: { 41 | let { channel, cache } = action; 42 | let item = state[channel] || {}; 43 | return { 44 | ...state, 45 | [channel]: { 46 | ...item, 47 | cache, 48 | lastReload: new Date() 49 | } 50 | }; 51 | } 52 | 53 | case actionTypes.channelCaches.APPEND: { 54 | let s = clone(state), d = action.data; 55 | 56 | if (!s[d.channel]) { 57 | s[d.channel] = { cache: [] }; 58 | } 59 | 60 | // Clear any replaced ids, and then append to cache 61 | 62 | s[d.channel].cache = cacheMessageItem( 63 | clearReplacedIdsFromCache(s[d.channel].cache, d.prevIds), 64 | d 65 | ); 66 | 67 | return s; 68 | } 69 | 70 | case actionTypes.channelCaches.REMOVE: { 71 | let { channel } = action; 72 | return omit(state, channel); 73 | } 74 | 75 | case actionTypes.channelCaches.CLEARUSER: { 76 | let { channel, time, username } = action; 77 | let channelItem = state[channel] || {}; 78 | let cacheList = channelItem.cache || []; 79 | return { 80 | ...state, 81 | [channel]: { 82 | cache: cacheList.map(clearUserHandler(username, time)), 83 | lastReload: channelItem.lastReload 84 | } 85 | }; 86 | } 87 | 88 | case actionTypes.channelCaches.STARTEXPIRATION: { 89 | let { channel } = action; 90 | let item = state[channel]; 91 | if (item) { 92 | return { 93 | ...state, 94 | [channel]: { 95 | ...item, 96 | expirationTimer: startExpiringChannelCache(channel) 97 | } 98 | }; 99 | } 100 | break; 101 | } 102 | 103 | case actionTypes.channelCaches.STOPEXPIRATION: { 104 | let { channel } = action; 105 | let item = state[channel]; 106 | if (item) { 107 | clearTimeout(item.expirationTimer); 108 | 109 | return { 110 | ...state, 111 | [channel]: { 112 | ...item, 113 | expirationTimer: null 114 | } 115 | }; 116 | } 117 | } 118 | } 119 | 120 | return state; 121 | } 122 | -------------------------------------------------------------------------------- /public/src/js/chatview/ChannelUserList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { connect } from "react-redux"; 4 | import forOwn from "lodash/forOwn"; 5 | import without from "lodash/without"; 6 | 7 | import TimedUserItem from "../components/TimedUserItem.jsx"; 8 | 9 | const USER_SYMBOL_ORDER = ["~", "&", "@", "%", "+"]; 10 | 11 | class ChannelUserList extends PureComponent { 12 | // TODO: Change into a wrapper of ? 13 | 14 | groupUserList(userList) { 15 | var output = {}; 16 | 17 | forOwn(userList, (data, username) => { 18 | let { symbol } = data; 19 | 20 | if (!output[symbol]) { 21 | output[symbol] = []; 22 | } 23 | 24 | output[symbol].push({ username, ...data }); 25 | }); 26 | 27 | forOwn(output, (list) => { 28 | list.sort(function(a, b) { 29 | if (a && b) { 30 | return a.username.toLowerCase() 31 | .localeCompare(b.username.toLowerCase()); 32 | } 33 | return -1; 34 | }); 35 | }); 36 | 37 | return output; 38 | } 39 | 40 | sortedUserList(userList) { 41 | var output = []; 42 | const grouped = this.groupUserList(userList); 43 | const groups = Object.keys(grouped); 44 | 45 | if (!grouped || !groups || !groups.length) { 46 | return output; 47 | } 48 | 49 | // Default promoted symbols in order 50 | const addUsersOfSymbol = (symbol) => { 51 | if (grouped[symbol]) { 52 | output = output.concat(grouped[symbol]); 53 | } 54 | }; 55 | USER_SYMBOL_ORDER.forEach(addUsersOfSymbol); 56 | 57 | // Unrecognized symbols 58 | const unrecognizedGroups = without(groups, ...USER_SYMBOL_ORDER.concat([""])); 59 | if (unrecognizedGroups && unrecognizedGroups.length) { 60 | unrecognizedGroups.forEach(addUsersOfSymbol); 61 | } 62 | 63 | // The rest; people without symbol 64 | addUsersOfSymbol(""); 65 | 66 | return output; 67 | } 68 | 69 | render() { 70 | const { channel, lastSeenUsers, userList } = this.props; 71 | 72 | var userListNodes = null; 73 | 74 | if (userList) { 75 | const sortedList = this.sortedUserList(userList); 76 | userListNodes = sortedList.map((data) => { 77 | if (data) { 78 | let { displayName, username, symbol } = data; 79 | let lastSeen = lastSeenUsers[username]; 80 | 81 | return ; 90 | } 91 | }); 92 | } 93 | 94 | return ( 95 |
      { userListNodes }
    96 | ); 97 | } 98 | } 99 | 100 | ChannelUserList.propTypes = { 101 | channel: PropTypes.string, 102 | lastSeenUsers: PropTypes.object, 103 | userList: PropTypes.object 104 | }; 105 | 106 | const mapStateToProps = function(state, ownProps) { 107 | let { channel } = ownProps; 108 | let { channelUserLists, lastSeenUsers } = state; 109 | 110 | return { 111 | lastSeenUsers, 112 | userList: channelUserLists[channel] 113 | }; 114 | }; 115 | 116 | export default connect(mapStateToProps)(ChannelUserList); 117 | -------------------------------------------------------------------------------- /public/src/js/lib/dataExpiration.js: -------------------------------------------------------------------------------- 1 | // Data expiration: Slowly killing and freeing up resources in the frontend 2 | // that we no longer need 3 | 4 | import actions from "../actions"; 5 | import store from "../store"; 6 | import { getCurrentData } from "./multiChat"; 7 | import { getRouteData } from "./routeHelpers"; 8 | 9 | const CACHE_EXPIRATION_TIME = 300000; // 5 minutes 10 | 11 | var prevPathname = ""; 12 | 13 | export function startExpiringChannelCache(channel) { 14 | return setTimeout(function() { 15 | console.log(`Expiring cache for channel ${channel}`); 16 | store.dispatch(actions.channelCaches.remove(channel)); 17 | }, CACHE_EXPIRATION_TIME); 18 | } 19 | 20 | export function startExpiringUserCache(username) { 21 | return setTimeout(function() { 22 | console.log(`Expiring cache for user ${username}`); 23 | store.dispatch(actions.userCaches.remove(username)); 24 | }, CACHE_EXPIRATION_TIME); 25 | } 26 | 27 | export function handleItemVisible(item) { 28 | let { type, query } = item; 29 | 30 | if (type === "channel") { 31 | store.dispatch(actions.channelCaches.stopExpiration(query)); 32 | } 33 | 34 | else if (type === "user") { 35 | store.dispatch(actions.userCaches.stopExpiration(query)); 36 | } 37 | } 38 | 39 | export function handleItemNoLongerVisible(item) { 40 | let { type, query } = item; 41 | 42 | if (type === "channel") { 43 | store.dispatch(actions.channelCaches.startExpiration(query)); 44 | } 45 | 46 | else if (type === "user") { 47 | store.dispatch(actions.userCaches.startExpiration(query)); 48 | } 49 | } 50 | 51 | export function updateMultiChatVisibility(visible, layout) { 52 | let { currentLayout } = getCurrentData(); 53 | 54 | if (!layout) { 55 | layout = currentLayout; 56 | } 57 | 58 | if (layout) { 59 | layout.forEach((item) => { 60 | if (item) { 61 | if (visible) { 62 | handleItemVisible(item); 63 | } 64 | 65 | else { 66 | handleItemNoLongerVisible(item); 67 | } 68 | } 69 | }); 70 | } 71 | } 72 | 73 | function handleLocationChange(location) { 74 | let { pathname } = location; 75 | 76 | // Clear all line infos on location change 77 | store.dispatch(actions.lineInfo.clear()); 78 | 79 | // If user or channel cache: 80 | // Start expiration timer on cache you just went away from 81 | 82 | let prevRouteData = getRouteData(prevPathname); 83 | 84 | if (prevRouteData) { 85 | if (prevRouteData.type === "home") { 86 | // Leaving multichat 87 | updateMultiChatVisibility(false); 88 | } 89 | 90 | else { 91 | handleItemNoLongerVisible(prevRouteData); 92 | } 93 | } 94 | 95 | // Stop expiration timer on cache you just went into 96 | 97 | let currentRouteData = getRouteData(pathname); 98 | 99 | if (currentRouteData) { 100 | if (currentRouteData.type === "home") { 101 | // Entering multichat 102 | updateMultiChatVisibility(true); 103 | } 104 | 105 | else { 106 | handleItemVisible(currentRouteData); 107 | } 108 | } 109 | 110 | // Set prev pathname 111 | 112 | prevPathname = pathname; 113 | } 114 | 115 | export default function setUpDataExpiration(history) { 116 | history.listen(handleLocationChange); 117 | handleLocationChange(location); 118 | } 119 | --------------------------------------------------------------------------------