├── 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 |
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 |
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 |
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 |
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 |
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
;
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 |
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 ;
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 |
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 |
--------------------------------------------------------------------------------