master
Sven Slootweg 5 years ago
commit c1d3333f79

1
.gitignore vendored

@ -0,0 +1 @@
node_modules

@ -0,0 +1,32 @@
"use strict";
const budoExpress = require("budo-express");
const path = require("path");
budoExpress({
port: 3000,
debug: true,
expressApp: require("../src/server/app"),
basePath: path.join(__dirname, ".."),
entryPath: "src/client/index.jsx",
publicPath: "public",
bundlePath: "js/bundle.js",
livereload: "**/*.{css,html,js}",
browserify: {
extensions: [".jsx"],
plugin: [
"browserify-hmr"
],
transform: [
["babelify", {
presets: ["@babel/preset-env", "@babel/preset-react"],
// plugins: ["react-hot-loader/babel"]
}],
// ["aliasify", {
// aliases: {
// "react-dom": "@hot-loader/react-dom"
// }
// }]
]
}
});

@ -0,0 +1,13 @@
"use strict";
const buildThing = require("build-thing");
let scssTask = buildThing.core.defineTask(() => {
return buildThing.core.pipeline([
buildThing.core.watch("src/client/scss/**/*.scss"),
buildThing.scssTransform(),
buildThing.core.saveToDirectory("public/css")
]);
});
buildThing.runner(scssTask);

@ -0,0 +1,113 @@
connected
disconnected
user changed status
user changed display name (DB ack)
user started typing
user paused typing
user resumed typing
user stopped typing
friend changed status
friend changed display name
friend started typing
friend paused typing
friend resumed typing
friend stopped typing
message received from friend - realtime & delayed (DB ack)
message sent to friend (DB ack)
message was delivered to friend
message was queued for offline delivery
added friend
removed friend
received friend request
accepted friend request
rejected friend request
## Security levels
==================
Low: Unauthenticated other party
Normal: Authenticated other party, history readable by any new device
High: Authenticated other party, no logs/history
## OLM
======
All public keys are published.
Ed25519 fingerprint keypair: Signing messages(?), device identification
Curve25519 identity keypair: Shared secret establishment, Olm session establishment
Curve25519 one-time keypairs: Olm session establishment but only single-use (offline messages?)
Megolm key: Group message encryption, via AES256 and HMAC-SHA256 derivation, derived after each sent message
Ed25519 Megolm signing keypair: Signing messages, specific to each Megolm session, public key shared with each praticipant
KEY GENERATION EVENTS
---------------------
First device start: (via olm_create_account)
Fingerprint keypair
Identity keypair
When needed as one-time keys run out: (via olm_account_generate_one_time_keys and olm_account_mark_keys_as_published)
One-time keypairs
Group chat creation:
--
FOR LATER:
what if the DB breaks before a message-receipt notification is processed? the message will forever look unreceived?
---
react-hot-loader@4.4.0-1:
version "4.4.0-1"
resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.4.0-1.tgz#2774f7fd0e62a9756f71a817acfc603b4152c0d0"
integrity sha512-0dlxQMQ4ndEzV4h1u/GfHgM7U6HXR4C7PIQ9sy59Lr0O8zCygj6lGf9HMfOEHSsO8+p2dVR0DbKbfZAMKImVLQ==
dependencies:
fast-levenshtein "^2.0.6"
global "^4.3.0"
hoist-non-react-statics "^2.5.0"
prop-types "^15.6.1"
react-lifecycles-compat "^3.0.4"
shallowequal "^1.0.2"
source-map "^0.7.3"
-------
Broken hot-reloading:
- Exported components
- Redux-connected components
Working hot-reloading:
- Everything else (including when referenced *from* a broken component!)
-------
Important project-wide FIXMEs before deployment:
- Verify that all client-side logicHandler validation code has a server-side equivalent
-------
Long-term improvements:
- Find a more optimized hooks API for Redux, so that we don't have to re-render the entire tree every time the store contents change.
------
Attached vs. Detached request/form state
- Attached: Request/form state is related to a particular resource (eg. deleting an existing resource)
Should be stored within the resource itself? As it should affect all UIs for that resource.
- Detached: Form state for free-standing forms that are not associated with any particular resource (yet), and that therefore can't usually conflict with other forms, eg. "create new resource" forms.
Should be stored in separate FormState data, since the state is only relevant to the specific form. Possibly could be scoped, though? If the same "create X" form exists for multiple resources... maybe could be done with statePath?

@ -0,0 +1,37 @@
{
"dependencies": {
"assure-array": "^1.0.0",
"bluebird": "^3.5.3",
"create-react-class": "^15.6.3",
"date-fns": "^1.30.1",
"debounce": "^1.2.0",
"default-value": "^1.0.0",
"dotty": "^0.1.0",
"express": "^4.16.4",
"form-serialize": "^0.7.2",
"immutable": "^4.0.0-rc.12",
"map-obj": "^3.0.0",
"nanoid": "^2.0.1",
"react-redux": "^6.0.0",
"redux": "^4.0.1",
"redux-logic": "^2.1.1"
},
"devDependencies": {
"@babel/core": "^7.2.2",
"@babel/preset-env": "^7.3.1",
"@babel/preset-react": "^7.0.0",
"@hot-loader/react-dom": "^16.8.1",
"aliasify": "^2.1.0",
"babelify": "^10.0.0",
"browserify-hmr": "^0.3.7",
"classnames": "^2.2.6",
"document-ready-promise": "^3.0.1",
"react": "^16.8.1",
"react-dom": "npm:@hot-loader/react-dom",
"react-hot-loader": "^4.4.0-1"
},
"scripts": {
"dev": "NODE_ENV=development nodemon --ext js,jsx --ignore src/client --ignore src/shared --ignore node_modules bin/server.js"
},
"license": "WTFPL OR CC0-1.0"
}

@ -0,0 +1,298 @@
body {
/* FIXME: Fonts */
font-family: "Noto Serif", serif;
background-color: #f1f0d8;
margin: 0;
padding: 0;
cursor: default;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none; }
.frame {
position: relative;
/* For ensuring that .frameShadow anchors against the correct element. */
padding: .7em;
overflow: hidden;
border-radius: 8px;
background-color: white; }
.frameShadow {
position: absolute;
z-index: 9999999;
left: 0px;
top: 0px;
right: 0px;
bottom: 0px;
pointer-events: none;
border-radius: 8px;
box-shadow: inset 0px 1px 2px rgba(71, 71, 71, 0.384); }
.picker {
background: transparent;
border: 1px solid #d4d4d4;
display: grid;
grid-template-columns: 1fr auto;
border-radius: 4px;
padding: .4em; }
.picker:hover {
background-color: transparent;
border: 1px solid #4d4d4d; }
.picker:active, .picker:focus {
background-color: #f8f8f6;
border: 1px solid black; }
.picker .pickerArrow {
margin: 0 .5em;
color: #636363; }
.statusBall, .statusBallPlaceholder {
display: inline-block;
width: .6em;
height: .6em;
border: .15em solid transparent;
margin: 0 .5em 0 .2em;
vertical-align: -.05em; }
.statusBall {
border-radius: 1em;
background-color: #292929; }
.statusBall.online {
background-color: #097a09; }
.statusBall.away {
background-color: #be571b; }
.statusBall.offline {
background-color: #bbbbbb; }
input, textarea {
font-family: serif;
font-size: 1em; }
form.horizontal input, form.horizontal textarea {
background: transparent;
border: 1px solid #d4d4d4;
padding: .4em .6em;
border-radius: 4px;
margin-right: .8em; }
form.horizontal input.invalid, form.horizontal textarea.invalid {
background-color: #ffebeb;
border-color: #8d0000; }
.error {
padding: .7em;
color: white;
background-color: #a00000;
border: 2px solid #f01d1d; }
.error.form {
display: inline-block;
font-size: .9em;
margin-top: -.5em;
margin-bottom: 1em;
padding: .4em .7em; }
/* The below spinner code is based from https://loading.io/css/ */
.spinner, .messageSpinner {
display: inline-block; }
.spinner:after, .messageSpinner:after {
content: " ";
display: block;
border-radius: 50%;
animation: spinner 1.2s linear infinite; }
.spinner {
width: 1em;
height: 1em; }
.spinner:after {
width: 1em;
height: 1em;
margin: 0 .8em;
border: 3px solid rgba(148, 0, 0, 0.7);
border-color: rgba(148, 0, 0, 0.7) transparent rgba(148, 0, 0, 0.7) transparent; }
.messageSpinner {
width: 0;
height: .8em; }
.messageSpinner:after {
width: 0.7em;
height: 0.7em;
margin: 0 -1.5em;
border: 2px solid silver;
border-color: silver transparent silver transparent;
animation-duration: 3s; }
@keyframes spinner {
0% {
transform: rotate(0deg); }
100% {
transform: rotate(360deg); } }
table {
width: 100%; }
table th, table td {
text-align: left;
padding: .2em .4em; }
table td {
border-top: 1px solid silver; }
table td.status button {
padding: .1em .5em;
border-width: 1px; }
table tr.consumed {
color: gray; }
table tr.consumed td.status {
font-style: italic;
font-size: .8em; }
.app {
display: grid;
grid-column-gap: .7em;
grid-row-gap: .7em;
grid-template-columns: 280px 1fr;
grid-template-rows: 100%;
box-sizing: border-box;
padding: .7em;
height: 100vh;
width: 100vw; }
.app .sidebar {
display: grid;
grid-column-gap: .7em;
grid-row-gap: .7em;
grid-template-rows: auto 1fr; }
.app .sidebar .profileCard {
display: grid;
grid-template-rows: auto auto; }
.app .sidebar .profileCard .profileInformation {
border-bottom: 1px solid silver;
padding-bottom: .5em;
margin-bottom: .5em; }
.app .sidebar .profileCard .profileInformation .displayName {
font-size: 2em;
margin-bottom: -.2em; }
.app .sidebar .profileCard .profileInformation .username {
color: #464646;
font-style: italic;
font-size: .8em; }
.app .sidebar .profileCard .statusMessage {
margin-top: .4em; }
.app .sidebar .profileCard .statusMessage input {
background: transparent;
border: 1px solid #d4d4d4;
width: 100%;
padding: .2em .5em;
border-radius: 4px; }
.app .sidebar .profileCard .statusMessage input:hover {
background-color: transparent;
border: 1px solid #4d4d4d; }
.app .sidebar .profileCard .statusMessage input:active, .app .sidebar .profileCard .statusMessage input:focus {
background-color: #f8f8f6;
border: 1px solid black; }
.app .sidebar .friendList {
padding: 0;
overflow-y: auto; }
.app .sidebar .friendList .friend {
padding: .8em .6em;
border-bottom: 1px solid #cfcfcf; }
.app .sidebar .friendList .friend:hover {
background: #f5f5ee; }
.app .sidebar .friendList .friend.selected {
color: white;
font-weight: bold;
background-color: #1492cc; }
.app .sidebar .friendList .friend.selected .statusBall {
border-color: #ccccac; }
.app .sidebar .friendList .friend.meta {
color: #757575;
font-style: italic; }
.app .sidebar .friendList .friend.meta.selected {
color: #ececec; }
.app .mainView {
display: grid;
grid-template-rows: auto 1fr; }
.app .mainView .header {
border-bottom: 1px solid silver;
padding-bottom: 1em;
margin-bottom: 1em; }
.app .mainView .header .friendInformation .name {
cursor: auto;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
display: grid;
grid-template-columns: 1fr auto;
font-size: 2em; }
.app .mainView .header .friendInformation .name .username {
color: silver;
font-style: italic;
font-size: .8em; }
.app .mainView .header .friendInformation .statusDisplay {
display: flex; }
.app .mainView .header .friendInformation .statusDisplay .statusMessage {
cursor: auto;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
margin-left: .3em;
color: #585858; }
.app .mainView .header .friendActions {
display: flex;
margin-top: 1em; }
.app .mainView .header .friendActions .action {
background: transparent;
border: 1px solid #d4d4d4;
margin: 0 .3em;
font-size: .9em;
padding: .2em .5em;
border-radius: 4px; }
.app .mainView .header .friendActions .action:hover {
background-color: transparent;
border: 1px solid #4d4d4d; }
.app .mainView .header .friendActions .action:active, .app .mainView .header .friendActions .action:focus {
background-color: #f8f8f6;
border: 1px solid black; }
.app .mainView .header .headerText {
font-size: 2em; }
.app .mainView .contents {
overflow-y: auto; }
.app .mainView .contents h2 {
font-weight: normal;
margin-top: 0; }
.app .mainView .contents p {
line-height: 1.5em;
margin: .7em 0; }
.app .mainView .contents.conversation {
cursor: auto;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
padding: 0 1.5em; }
.app .mainView .contents.conversation .messageGroup {
margin-bottom: .8em; }
.app .mainView .contents.conversation .messageGroup .message {
margin-bottom: .4em; }
.app .mainView .contents.conversation .messageGroup .metadata {
display: flex;
border-bottom: 1px solid #cfcfcf; }
.app .mainView .contents.conversation .messageGroup .metadata .sender {
font-weight: bold;
flex-grow: 1; }
.app .mainView .contents.conversation .messageGroup .metadata .timestamp {
margin-right: .6em;
color: #757575; }
.app .mainView .contents.conversation .messageGroup.received .sender {
color: #414141; }
.app .conversationView {
display: grid;
grid-column-gap: .7em;
grid-row-gap: .7em;
grid-template-rows: 1fr 50px; }
.app .conversationView .inputBox {
/* FIXME: Why did 100% width/height on .form not work? */
display: grid; }
.app .conversationView .inputBox .form {
display: grid; }
.app .conversationView .inputBox .form form {
display: grid;
grid-column-gap: .5em;
margin: 0;
grid-template-columns: 1fr 80px; }
.app .conversationView .inputBox .form form input, .app .conversationView .inputBox .form form textarea {
border: none;
resize: none; }

@ -0,0 +1,122 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Chat</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="appContainer">
<!-- <div class="app">
<div class="sidebar">
<div class="profileCard frame">
<div class="frameShadow"></div>
<div class="profileInformation">
<div class="displayName">
Sven Slootweg
</div>
<div class="username">
joepie91@example.com
</div>
</div>
<div class="statusPicker picker">
<div class="selectedOption">
<div class="statusBall online"></div>Online
</div>
<div class="pickerArrow"></div>
</div>
<div class="statusMessage">
<input type="text" placeholder="Enter a status message here...">
</div>
</div>
<div class="friendList frame">
<div class="frameShadow"></div>
<div class="friend selected">
<div class="statusBall away"></div>John Doe
</div>
<div class="friend">
<div class="statusBall offline"></div>Jane Doe
</div>
<div class="friend meta">
Add or invite a friend...
</div>
</div>
</div>
<div class="mainView frame">
<div class="frameShadow"></div>
<div class="header">
<div class="friendInformation">
<div class="name">
<div class="displayName">
John Doe
</div>
<div class="username">
johndoe@example.com
</div>
</div>
<div class="statusDisplay">
<div class="status">
<div class="statusBall away"></div>
Busy
</div>
<div class="statusMessage">
Status message goes here...
</div>
</div>
</div>
<div class="friendActions">
<div class="action sendPicture">Send picture</div>
<div class="action sendFile">Send file</div>
<div class="action remove">Remove friend</div>
</div>
</div>
<div class="contents conversation">
<div class="message sent">
<div class="metadata">
<div class="timestamp">
22:01
</div>
<div class="sender">
You
</div>
</div>
<div class="body">
Foo
</div>
</div>
<div class="message received">
<div class="metadata">
<div class="timestamp">
22:02
</div>
<div class="sender">
John Doe
</div>
</div>
<div class="message">
Bar!
</div>
</div>
<div class="message sent">
<div class="metadata">
<div class="timestamp">
22:05
</div>
<div class="sender">
You
</div>
</div>
<div class="message">
Baz...
</div>
</div>
</div>
</div>
</div> -->
</div>
<script src="/js/bundle.js"></script>
<script src="/budo/livereload.js"></script>
</body>
</html>

@ -0,0 +1,21 @@
"use strict";
module.exports = {
connected: function (username) {
return {
type: "connected",
username: username
};
},
disconnected: function () {
return {
type: "disconnected"
};
},
userChangedStatus: function (status) {
return {
type: "userChangedStates",
status: status
};
}
};

@ -0,0 +1,11 @@
"use strict";
const normalize = require("./normalize");
module.exports = function mapActions(action, mapper) {
if (action == null) {
return action;
} else {
return normalize(action, true).map(mapper);
}
};

@ -0,0 +1,19 @@
"use strict";
const assureArray = require("assure-array");
function normalizeSingleAction(action) {
if (typeof action === "string") {
return { type: action };
} else {
return action;
}
}
module.exports = function normalizeAction(action, makeArray = false) {
if (makeArray === true) {
return assureArray(action).map(normalizeSingleAction);
} else {
return normalizeSingleAction(action);
}
};

@ -0,0 +1,21 @@
"use strict";
const mapActions = require("./map-actions");
module.exports = function withArgumentActionMapper(func, mapper, options = {}) {
return function (action) {
if (action == null) {
return func(action);
} else {
let mappedActions = mapActions(action, mapper);
if (options.outputSingleDispatch) {
return mappedActions.map((mappedAction) => {
return func(mappedAction);
});
} else {
return func(mappedActions);
}
}
}
};

@ -0,0 +1,17 @@
"use strict";
const React = require("react");
// const reactHotLoader = require("react-hot-loader");
const reactHotLoader = {hot: (_) => (component) => component};
const createReactClass = require("create-react-class");
const App = require("./components");
/* NOTE: This wrapper exists due to pureSFC use: https://github.com/gaearon/react-hot-loader/issues/1088#issuecomment-434912466 */
/* NOTE: This wrapper MUST exist in its own module that it is exported from, otherwise hot reloads will not work reliably (and produce "Cannot find module <...>" errors). */
module.exports = reactHotLoader.hot(module)(createReactClass({
displayName: "AppWrapper",
render: function () {
return <App />;
}
}));

@ -0,0 +1,116 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const Spinner = require("./spinner");
const {Form, Input, SubmitButton, FormSpinner} = require("./form");
const MainView = require("./main-view");
const useModule = require("../plumbing/use-module");
function AddFriendForm() {
return (
<Form module="uiAddFriend" className="horizontal">
<Input name="username" placeholder="Username" />
<SubmitButton>Add friend</SubmitButton>
<FormSpinner />
</Form>
);
}
function InviteFriendForm() {
return (
<Form module="uiInviteFriend" className="horizontal">
<Input name="note" placeholder="Note (optional)" />
<SubmitButton>Create new invite code</SubmitButton>
<FormSpinner />
</Form>
);
}
function InviteDeleteButton({invite}) {
let {dispatch} = useModule("invites");
function handleDelete(id) {
dispatch({
type: "delete",
inviteId: id
});
}
return (<>
<button disabled={invite.beingDeleted} onClick={() => handleDelete(invite.id)}>
Delete code
</button>
{invite.beingDeleted
? <Spinner />
: null
}
</>);
}
function InvitationsTable() {
let {state} = useModule("invites");
let {invites} = state;
return (
<table className="invites">
<thead>
<tr>
<th>Invite code</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
{invites.toList().map((invite) => {
return (
<tr key={invite.id} className={classnames({consumed: invite.consumed})}>
<td>{invite.code}</td>
<td>{invite.note}</td>
<td className="status">
{(invite.consumed)
? "Code has been used"
: <InviteDeleteButton invite={invite} />
}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function ViewContents() {
/* FIXME: Quick flash-of-missing-form occurs, presumably due to the nested component hack in with InnerForm. */
return (
<MainView.Contents>
<h2>Add a friend</h2>
<p>If your friend already has an account, simply enter their username below and press the button! They'll immediately show up in your friends list, but they'll need to accept your friend request before you can talk to them.</p>
<AddFriendForm />
<h2>Invite a friend</h2>
<p>If your friend does not have an account yet, you can create an invite code for them below - they can then use this invite code to create an account. Once they do, you'll be added to each other's friend lists automatically.</p>
<p>Optionally, you may add a custom note for the invite code; this allows you to keep track of who you gave an invite code.</p>
<p><em>Invites are unlimited, but we keep track of who invited whom, for spam prevention reasons. Please only give invites to people you know; accounts that are used to provide automated public invites may be disabled.</em></p>
<InviteFriendForm />
<InvitationsTable />
</MainView.Contents>
);
};
module.exports = function AddFriendView() {
return (
<MainView.Container className="meta">
<MainView.Header>
<div className="headerText">
Add or invite a friend
</div>
</MainView.Header>
<ViewContents />
</MainView.Container>
);
};

@ -0,0 +1,218 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const assert = require("assert");
const defaultValue = require("default-value");
const dateFns = require("date-fns");
const debounce = require("debounce");
const Frame = require("./frame");
const MainView = require("./main-view");
const StatusBall = require("./status-ball");
const {Form, Input, SubmitButton} = require("./form");
const humanReadableStatus = require("../human-readable-status");
const useStore = require("../plumbing/use-store");
const useModule = require("../plumbing/use-module");
const useTimeout = require("../use-timeout");
const useLockableScroll = require("../use-lockable-scroll");
/* FIXME: Input persistence, both 1) contents when switching tabs, and 2) focus */
function Status({friend}) {
if (friend.statusVisible === true) {
return (
<div className="status">
<StatusBall status={friend.status} statusVisible={friend.statusVisible} />
{humanReadableStatus(friend.status)}
</div>
);
} else {
return null;
}
}
function StatusMessage({friend}) {
let prefix = (friend.statusVisible === true) ? "• " : "";
return (
<div className="statusMessage">
{(friend.statusMessage != null)
? `${prefix}${friend.statusMessage}`
: undefined}
</div>
);
}
function ConversationFriendInformation({friend}) {
return (
<div className="friendInformation">
<div className="name">
<div className="displayName">
{defaultValue(friend.displayName, friend.username)}
</div>
<div className="username">
{friend.username}
</div>
</div>
<div className="statusDisplay">
<Status friend={friend} />
<StatusMessage friend={friend} />
</div>
</div>
);
}
function ConversationFriendAction({className, children}) {
return (
<div className={classnames("action", className)}>
{children}
</div>
);
}
function ConversationFriendActions() {
return (
<div className="friendActions">
<ConversationFriendAction className="sendPicture">
Send picture
</ConversationFriendAction>
<ConversationFriendAction className="sendFile">
Send file
</ConversationFriendAction>
<ConversationFriendAction className="remove">
Remove friend
</ConversationFriendAction>
</div>
);
}
function ConversationViewHeader({friend}) {
return (
<MainView.Header>
<ConversationFriendInformation friend={friend} />
<ConversationFriendActions />
</MainView.Header>
);
}
function MessageSpinner() {
return <div className="messageSpinner" />;
}
function Message({message}) {
let sendPeriodElapsed = useTimeout(2000, message.timestamp);
return (
<div className="message sent">
<div className="body">
{(message.dbStored === false && sendPeriodElapsed)
? <MessageSpinner />
: null}
{message.body}
</div>
</div>
);
}
function MessageGroup({messageGroup}) {
let {messages} = useModule("messages").state;
let {friends} = useModule("friends").state;
let senderName;
if (messageGroup.direction === "sent") {
senderName = "You";
} else {
let friend = friends.get(messageGroup.sender);
senderName = defaultValue(friend.displayName, friend.username);
}
/* FIXME: Why is there a hard-coded `sent` class name? */
return (
<div className="messageGroup sent">
<div className="metadata">
<div className="timestamp">
{dateFns.format(messageGroup.firstTimestamp, "HH:mm")}
</div>
<div className="sender">
{senderName}
</div>
</div>
{messageGroup.messageIds.map((messageId) => {
let message = messages.get(messageId);
return <Message key={message.id} message={message} />;
})}
</div>
);
}
function ConversationViewContents({messageGroups, friend}) {
let {dispatch} = useModule("friends");
let {getState} = useStore();
let {scrollTop, scrollLocked} = friend;
/* MARKER: Get rid of local locked state, it's totally useless when dealing with views anyway */
/* MARKER: Move debounce to within lockable-scroll abstraction, based on scrollKey for invalidation */
let [locked, setRef] = useLockableScroll({
scrollTop: scrollTop,
locked: scrollLocked,
scrollKey: `${friend.messageIds.size}::${friend.username}`,
onScroll: debounce((locked, scrollTop) => {
/* HACK: The below check is because the `debounce` will defer a call to this function when switching a tab, and so this function will be called and cause an erroneous scroll-set *after* we've already ended up on another user's tab. */
let {selectedTabType, selectedTab} = getState();
if (selectedTabType === "conversation" && selectedTab === friend.username) {
dispatch({
type: "scrolled",
username: friend.username,
locked: locked,
scrollTop: scrollTop
});
}
}, 100)
});
return (
<MainView.Contents ref={setRef} className={classnames("conversation", {locked: friend.scrollLocked})}>
{messageGroups.map((messageGroup) => {
return <MessageGroup key={messageGroup.id} messageGroup={messageGroup} />;
})}
</MainView.Contents>
);
}
function MessageInput({friend}) {
return (
<Frame className="inputBox">
<Form module="uiMessageInput" stateKey={friend.username}>
{/* <TextArea name="body" /> */}
<Input name="body" autoFocus />
<SubmitButton>Send</SubmitButton>
</Form>
</Frame>
);
}
module.exports = function ConversationView() {
let {friends} = useModule("friends").state;
let {state} = useStore();
let {selectedTab, selectedTabType} = state;
assert(selectedTabType === "conversation");
let friend = friends.get(selectedTab);
assert(friend != null);
return (
<div className="conversationView">
<MainView.Container>
<ConversationViewHeader friend={friend} />
<ConversationViewContents friend={friend} messageGroups={friend.messageGroups.toArray()} />
</MainView.Container>
<MessageInput friend={friend} />
</div>
);
};

@ -0,0 +1,136 @@
"use strict";
const React = require("react");
const dotty = require("dotty");
const classnames = require("classnames");
const formSerialize = require("form-serialize");
const Spinner = require("./spinner");
const useModule = require("../plumbing/use-module");
const types = require("../plumbing/types");
function getFormState(state, key) {
if (state.formState.has(key)) {
return state.formState.get(key);
} else {
return types.UiFormState();
}
}
let FormContext = React.createContext();
function Form({module, statePath, className, children, stateKey}) {
let {state, dispatch} = useModule(module);
let [formRef, setFormRef] = React.useState();
let formState = getFormState(state, stateKey);
function handleSubmit(event) {
event.preventDefault();
if (formRef != null) {
let formData = formSerialize(formRef, {hash: true});
dispatch({
type: "submit",
formData: formData,
key: stateKey
});
}
}
React.useEffect(() => {
if (formRef != null) {
for (let element of formRef.querySelectorAll("input")) {
element.value = "";
}
}
}, [formState.resetCounter]);
let contextValue = {
formState: formState,
formStateKey: stateKey,
submit: handleSubmit,
handleBlur: function (event) {
dispatch({
type: "blur",
key: stateKey, /* FIXME: Abstract this out everywhere */
field: event.target.name
});
},
handleFocus: function (event) {
dispatch({
type: "focus",
key: stateKey,
field: event.target.name
});
},
handleUnmount: function () {
}
}
return (
<div className="form">
<form ref={setFormRef} className={className}>
<FormContext.Provider value={contextValue}>
{children}
</FormContext.Provider>
</form>
{formState.errorMessage != null
? <div className="error form">{formState.errorMessage}</div>
: null}
</div>
);
}
function Input({type="text", name, placeholder, className, autoFocus}) {
let {formState, formStateKey, handleBlur, handleFocus} = React.useContext(FormContext);
let [inputRef, setInputRef] = React.useState();
let isInvalid = formState.invalidFields.has(name);
/* The below restores focus after re-rendering the form (eg. because the resetCounter was triggered after a form submission) */
React.useEffect(() => {
let needsFocus = inputRef != null && formState.wasFocused === name;
if (needsFocus) {
inputRef.focus();
}
}, [inputRef, formState.wasFocused, formState.resetCounter]);
React.useEffect(() => {
/* MARKER: Re-focus after form submission */
if (autoFocus === true && inputRef != null) {
inputRef.focus();
}
}, [name, formStateKey, inputRef]);
return <input ref={setInputRef} onBlur={handleBlur} onFocus={handleFocus} disabled={formState.processing} className={classnames(className, {invalid: isInvalid})} type={type} name={name} placeholder={placeholder} />;
}
function TextArea({name, placeholder, className}) {
let {formState} = React.useContext(FormContext);
let isInvalid = formState.invalidFields.has(name);
return <textarea disabled={formState.processing} className={classnames(className, {invalid: isInvalid})} name={name} placeholder={placeholder} />;
}
function SubmitButton({children}) {
let {submit, formState} = React.useContext(FormContext);
return <button disabled={formState.processing} type="submit" onClick={submit}>{children}</button>;
}
function FormSpinner() {
let {formState} = React.useContext(FormContext);
if (formState.processing) {
return <Spinner />;
} else {
return null;
}
}
module.exports = {Form, Input, TextArea, SubmitButton, FormSpinner};

@ -0,0 +1,15 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
function Frame({className, children}) {
return (
<div className={classnames("frame", className)}>
<div className="frameShadow"></div>
{children}
</div>
);
}
module.exports = Frame;

@ -0,0 +1,106 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const defaultValue = require("default-value");
const Frame = require("./frame");
const StatusBall = require("./status-ball");
const useStore = require("../plumbing/use-store");
const useModule = require("../plumbing/use-module");
function FriendItem({children, className, selected, onClick}) {
return (
<div className={classnames("friend", className, {selected: selected})} onClick={onClick}>
{children}
</div>
);
}
function Friend({friend, selected, onClick}) {
return (
<FriendItem selected={selected} onClick={onClick}>
<StatusBall status={friend.status} statusVisible={friend.statusVisible} />
{defaultValue(friend.displayName, friend.username)}
</FriendItem>
);
}
function FriendMeta({children, onClick, selected}) {
return (
<FriendItem className="meta" onClick={onClick} selected={selected}>
{children}
</FriendItem>
);
}
module.exports = function FriendList() {
let {state, dispatch} = useStore();
let {selectedTab, selectedTabType} = state;
let selectedAddFriendTab = (selectedTabType=== "meta" && selectedTab === "addFriend");
let {friends} = useModule("friends").state;
function selectConversationTab(username) {
dispatch({
type: "selectedConversationTab",
username: username
});
}
function selectAddFriendTab() {
dispatch({ type: "selectedAddFriendTab" });
}
return (
<Frame className="friendList">
{friends.toList().toArray().map((friend) => {
let selected = (selectedTabType === "conversation" && selectedTab === friend.username);
return <Friend key={friend.username} friend={friend} selected={selected} onClick={() => selectConversationTab(friend.username)} />;
})}
<FriendMeta selected={selectedAddFriendTab} onClick={selectAddFriendTab}>
Add or invite a friend...
</FriendMeta>
</Frame>
);
};
// let ConnectedFriendList = reactRedux.connect((state) => {
// return {
// /* TODO: Memoize */
// friends: state.friends.toList().toArray(), /* ?! Why can't a Map be directly converted to an array of values... */
// selectedTabType: state.selectedTabType,
// selectedTab: state.selectedTab
// };
// }, {
// selectAddFriendTab: () => {
// return {
// type: "selectedAddFriendTab"
// };
// },
// selectConversationTab: (username) => {
// return {
// type: "selectedConversationTab",
// username: username
// };
// }
// })(function FriendList({friends, selectedTabType, selectedTab, selectAddFriendTab, selectConversationTab}) {
// /* FIXME: Figure out a nicer pattern for dealing with tab selection... this is a mess. */
// let selectedAddFriendTab = (selectedTabType=== "meta" && selectedTab === "addFriend");
// return (
// <Frame className="friendList">
// {friends.map((friend) => {
// let selected = (selectedTabType === "conversation" && selectedTab === friend.username);
// return <Friend key={friend.username} friend={friend} selected={selected} onClick={() => selectConversationTab(friend.username)} />;
// })}
// <FriendMeta selected={selectedAddFriendTab} onClick={selectAddFriendTab}>
// Add or invite a friend...
// </FriendMeta>
// </Frame>
// );
// });
// module.exports = ConnectedFriendList;

@ -0,0 +1,73 @@
"use strict";
const React = require("react");
const reactRedux = require("react-redux");
const ProfileCard = require("./profile-card");
const FriendList = require("./friend-list");
const ConversationView = require("./conversation-view");
const AddFriendView = require("./add-friend-view");
const match = require("../match");
const data = require("../plumbing/data");
const mockInit = require("../init");
let MainViewArea = reactRedux.connect((state) => {
return {
selectedTabType: state.selectedTabType,
selectedTab: state.selectedTab
};
})(function MainViewArea({selectedTabType, selectedTab}) {
/* TODO: Log invalidly called view configurations */
return match(selectedTabType, {
conversation: () => {
return <ConversationView />;
},
meta: () => {
return match(selectedTab, {
addFriend: () => {
return <AddFriendView />;
},
_: () => {
<div className="error">ERROR: Unknown view</div>
}
})
},
_: () => {
if (selectedTabType == null) {
return <div></div>;
} else {
return <div className="error">ERROR: Unknown view type: {selectedTabType}</div>;
}
}
});
});
module.exports = function App() {
let [store, setStore] = React.useState();
React.useEffect(() => {
let newStore = data.createStore();
mockInit(newStore);
setStore(newStore);
}, []);
if (store != null) {
return (
<reactRedux.Provider store={store}>
<div className="app">
<div className="sidebar">
<ProfileCard />
<FriendList />
</div>
<MainViewArea />
</div>
</reactRedux.Provider>
);
} else {
return null;
}
};

@ -0,0 +1,36 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
const Frame = require("./frame");
function MainViewHeader({children, className}) {
return (
<div className={classnames("header", className)}>
{children}
</div>
);
}
function MainViewContainer({children, className}) {
return (
<Frame className={classnames("mainView", className)}>
{children}
</Frame>
);
}
let MainViewContents = React.forwardRef(({children, className}, ref) => {
return (
<div ref={ref} className={classnames("contents", className)}>
{children}
</div>
)
});
module.exports = {
Container: MainViewContainer,
Header: MainViewHeader,
Contents: MainViewContents
};

@ -0,0 +1,17 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
function Picker({className, children}) {
return (
<div className={classnames("picker", className)}>
<div className="selectedOption">
{children}
</div>
<div className="pickerArrow"></div>
</div>
);
}
module.exports = Picker;

@ -0,0 +1,49 @@
"use strict";
const React = require("react");
const reactRedux = require("react-redux");
const defaultValue = require("default-value");
const Frame = require("./frame");
const StatusBall = require("./status-ball");
const Picker = require("./picker");
const humanReadableStatus = require("../human-readable-status");
function StatusPicker({status}) {
return (
<Picker className="statusPicker">
<StatusBall status={status} statusVisible={true} />
{humanReadableStatus(status)}
</Picker>
);
}
function ProfileCard({status, displayName, username, statusMessage}) {
return (
<Frame className="profileCard">
<div className="profileInformation">
<div className="displayName">
{displayName}
</div>
<div className="username">
{username}
</div>
</div>
<StatusPicker status={status} />
<div className="statusMessage">
<input type="text" placeholder="Enter a status message here..." value={defaultValue(statusMessage, undefined)} />
</div>
</Frame>
);
}
let ConnectedProfileCard = reactRedux.connect((state) => {
return {
status: state.status,
displayName: state.displayName,
username: state.username,
statusMessage: state.statusMessage
};
})(ProfileCard);
module.exports = ConnectedProfileCard;

@ -0,0 +1,7 @@
"use strict";
const React = require("react");
module.exports = function Spinner() {
return <div className="spinner" />;
};

@ -0,0 +1,14 @@
"use strict";
const React = require("react");
const classnames = require("classnames");
function StatusBall({status, statusVisible}) {
if (statusVisible === true) {
return <div className={classnames("statusBall", status)} />;
} else {
return <div className="statusBallPlaceholder" />;
}
}
module.exports = StatusBall;

@ -0,0 +1,8 @@
"use strict";
module.exports = function groupMessages(messages) {
let lastUsername;
let currentGroup;
let groups = ;
};

@ -0,0 +1,12 @@
"use strict";
const match = require("./match");
module.exports = function humanReadableStatus(status) {
return match(status, {
online: "Online",
away: "Away",
offline: "Offline",
_: "Unknown status"
});
};

@ -0,0 +1,21 @@
"use strict";
const Promise = require("bluebird");
const React = require("react");
const ReactDOM = require("react-dom");
const documentReadyPromise = require("document-ready-promise");
// const reactHotLoader = require("react-hot-loader");
/* NOTE: This MUST be called before requiring any other components! */
// reactHotLoader.setConfig({
// pureSFC: true
// });
const AppWrapper = require("./app-wrapper");
Promise.try(() => {
return documentReadyPromise();
}).then(() => {
console.log("Mounting application...");
ReactDOM.render(<AppWrapper />, document.querySelector(".appContainer"));
});

@ -0,0 +1,164 @@
"use strict";
const dateFns = require("date-fns");
const types = require("./plumbing/types");
function viewInit(store) {
// store.dispatch({type: "selectedAddFriendTab"});
store.dispatch({type: "selectedConversationTab", username: "johndoe"});
}
module.exports = function (store) {
store.dispatch({
type: "connected",
username: "joepie91",
displayName: "Sven Slootweg",
status: "online",
friends: [types.Friend({
username: "johndoe",
displayName: "John Doe",
statusVisible: true,
status: "away",
statusMessage: "Gone shopping",
mutual: true
})]
});
store.dispatch({
type: "friends_added",
friend: types.Friend({
username: "foo",
statusVisible: true
})
});
store.dispatch({
type: "friends_added",
friend: types.Friend({
username: "janedoe",
displayName: "Jane Doe",
statusVisible: false,
statusMessage: "Some status message goes here",
mutual: true
})
});
store.dispatch({
type: "messages_add",
message: types.Message({
id: 0,
direction: "sent",
sender: "joepie91",
recipient: "johndoe",
body: "Hi yes hello",
timestamp: dateFns.subMinutes(Date.now(), 5),
dbStored: true
})
});
// store.dispatch({
// type: "messages_dbStored",
// messageId: 0
// });
store.dispatch({
type: "messages_add",
message: types.Message({
id: 1,
direction: "received",
sender: "johndoe",
recipient: "joepie91",
body: "hi",
timestamp: dateFns.subMinutes(Date.now(), 4)
})
});
store.dispatch({
type: "messages_add",
message: types.Message({
id: 2,
direction: "sent",
sender: "joepie91",
recipient: "johndoe",
body: "Foo Bar",
timestamp: Date.now(),
dbStored: true
})
});
store.dispatch({
type: "messages_add",
message: types.Message({
id: 3,
direction: "sent",
sender: "joepie91",
recipient: "johndoe",
body: "Foo Bar",
timestamp: Date.now()
})
});
for (let i = 0; i < 30; i++) {
store.dispatch({
type: "messages_add",
message: types.Message({
id: i+10,
direction: "sent",
sender: "joepie91",
recipient: "johndoe",
body: "Foo Bar",
timestamp: Date.now()
})
});
}
for (let i = 0; i < 60; i++) {
store.dispatch({
type: "messages_add",
message: types.Message({
id: i+190,
direction: "sent",
sender: "joepie91",
recipient: "foo",
body: "Foo Bar",
timestamp: Date.now()
})
});
}
store.dispatch({
type: "invites_created",
invite: types.Invite({
id: 1,
code: "abcdef",
note: "Some random note goes here"
})
});
store.dispatch({
type: "invites_created",
invite: types.Invite({
id: 2,
code: "ghijkl",
note: "Another note goes here"
})
});
// for (let i = 0; i < 30; i++) {
// store.dispatch({
// type: "invites_created",
// invite: types.Invite({
// id: i+10,
// code: "blahblah",
// note: "Blah blah blah"
// })
// });
// }
store.dispatch({
type: "invites_consumed",
inviteId: 1
});
viewInit(store);
};

@ -0,0 +1,41 @@
"use strict";
const Promise = require("bluebird");
let addFriendLogic = createLogic({
type: "uiAddFriend",
processOptions: {
dispatchReturn: true,
successType: "uiAddFriendDBStored",
failType: "uiAddFriendError"
},
validate: ({getState, action}, allow, reject) => {
/* QUESTION: Should we add the new friend to the list locally before acknowledgement from the server? What if the server then returns an error concerning the addition, should it be removed again? */
if (action.username.length === 0) {
/* Error: you must enter a username */
reject({
type: "uiAddFriendError",
error: true,
errorType: "noUsernameSpecified"
});
} else if (getState().friends.has(action.username)) {
/* Error: that user is already in your friends list */
reject({
type: "uiAddFriendError",
error: true,
errorType: "friendAlreadyInList"
});
} else {
allow(action);
}
},
process: ({action, api}, dispatch, done) => {
dispatch({
type: "uiAddFriendProcessing"
});
return api.addFriend({
username: action.username
});
}
});

@ -0,0 +1,21 @@
"use strict";
const assureArray = require("assure-array");
module.exports = function mapDispatch(dispatchFunc, mapper) {
return function dispatch(actions) {
/* FIXME: This is probably creating a lot of unnecessary array allocations when mapped dispatches are nested. */
let mappedActions = assureArray(actions).map((action) => {
if (typeof action === "string") {
return mapper({ type: action });
} else {
return mapper(action);
}
});
mappedActions.forEach((action) => {
dispatchFunc(action);
});
}
};

@ -0,0 +1,19 @@
"use strict";
module.exports = function match(value, options) {
if (options[value] != null) {
if (typeof options[value] === "function") {
return options[value]();
} else {
return options[value];
}
} else if (options._ != null) {
if (typeof options._ === "function") {
return options._();
} else {
return options._;
}
} else {
throw new Error(`No handler defined for value '${value}', and no catch-all defined either`);
}
};

@ -0,0 +1,51 @@
"use strict";
const types = require("../plumbing/types");
module.exports = {
name: "uiAddFriend",
mixins: [
require("./mixins/form")({
validate: ({globalState, formData, allow, reject}) => {
let {username} = formData;
if (username == null) {
reject({
errorType: "noUsernameSpecified",
errorMessage: "You must specify a username.",
invalidFields: ["username"]
});
} else if (globalState.modules.friends.friends.has(username)) {
reject({
errorType: "friendAlreadyInList",
/* TODO: Make error message clearer, maybe include display name of existing entry? */
errorMessage: "There's already a user with that username in your friends list.",
invalidFields: ["username"]
});
} else {
allow();
}
},
process: ({formData, api}) => {
return api.addFriend({
username: formData.username
});
},
success: ({result}) => {
let friend = types.Friend(result.friend);
return [{
type: "@friends_added",
friend: friend
}, {
type: "@friends_addedDbStored",
username: friend.username,
optional: true
}, {
type: "@selectedConversationTab",
username: friend.username
}];
}
})
]
};

@ -0,0 +1,95 @@
"use strict";
const immutable = require("immutable");
const types = require("../plumbing/types");
function removeFriendRequest(username) {
return function (state) {
return state.updateIn(["friendRequests"], (requests) => requests.delete(username));
}
}
module.exports = {
name: "friendRequests",
state: {
friendRequests: immutable.Map()
},
stateTransformations: {
received: (state, action) => {
let {request} = action;
return state.updateIn(["friendRequests"], (requests) => requests.set(request.username, request));
},
withdrawn: (state, action) => {
let {username} = action;
return state.update(removeFriendRequest(username));
},
accept: (state, action) => {
let {username} = action;
return state.setIn(["friendRequests", username, "beingAccepted"], true);
},
accepted: (state, action) => {
let {username} = action;
/* NOTE: This *only* handles the `friendRequests` entry, not the addition to the friends list. That's a separate `friendAdded` action. */
return state.update(removeFriendRequest(username));
},
reject: (state, action) => {
let {username} = action;
return state.setIn(["friendRequests", username, "beingRejected"], true);
},
rejected: (state, action) => {
let {username} = action;
return state.update(removeFriendRequest(username));
}
},
logicHandlers: {
accept: {
errorType: "acceptFailed",
process: ({action, api}) => {
return api.acceptFriendRequest({
username: action.username
});
},
success: ({result, action}) => {
return [{
type: "accepted",
username: action.username
}, {
type: "@friends_added",
friend: types.Friend(result.friend)
}];
},
failure: ({error}) => {
return {
errorMessage: error.message
};
}
},
reject: {
errorType: "rejectFailed",
process: ({action, api}) => {
return api.rejectFriendRequest({
username: action.username
});
},
success: ({action}) => {
return {
type: "rejected",
username: action.username
};
},
failure: ({error}) => {
return {
errorMessage: error.message
};
}
}
}
};

@ -0,0 +1,99 @@
"use strict";
const immutable = require("immutable");
const nanoid = require("nanoid");
const types = require("../plumbing/types");
module.exports = {
name: "friends",
state: {
friends: immutable.Map(),
},
stateTransformations: {
added: (state, action) => {
let {friend} = action;
return state.updateIn(["friends"], (friends) => friends.set(friend.username, friend));
},
removed: (state, action) => {
let {username} = action;
return state.updateIn(["friends"], (friends) => friends.delete(username));
},
startedTyping: (state, action) => {
return state.setIn(["friends", username, "isTyping"], true);
},
pausedTyping: (state, action) => {
return state.setIn(["friends", username, "pausedTyping"], true);
},
resumedTyping: (state, action) => {
return state.setIn(["friends", username, "pausedTyping"], false);
},
stoppedTyping: (state, action) => {
return state.setIn(["friends", username, "isTyping"], false);
},
changedStatus: (state, action) => {
let {username, status} = action;
/* TODO: Figure out a way to express the branching below in a functional, expression-based manner. */
let state_ = state.setIn(["friends", username, "status"], status);
if (status === "offline") {
return state_.mergeIn(["friends", username], {
isTyping: false,
pausedTyping: false
});
} else {
return state_;
}
},
changedStatusMessage: (state, action) => {
let {statusMessage} = action;
return state.setIn(["friends", username, "statusMessage"], statusMessage);
},
changedDisplayName: (state, action) => {
let {username, displayName} = action;
return state.setIn(["friends", username, "displayName"], displayName);
},
addedMessageToConversation: (state, action) => {
let {username, message} = action;
let messageGroups = state.getIn(["friends", username, "messageGroups"]);
let messageGroupCount = messageGroups.size;
let lastMessageGroup = messageGroups.last();
let shouldCreateNewMessageGroup = (lastMessageGroup == null) || (lastMessageGroup.direction !== message.direction);
let state_ = state.updateIn(["friends", username, "messageIds"], (messageIds) => {
return messageIds.push(message.id);
});
if (shouldCreateNewMessageGroup) {
return state_.updateIn(["friends", username, "messageGroups"], (messageGroups) => {
return messageGroups.push(types.MessageGroup({
id: nanoid(),
direction: message.direction,
sender: message.sender,
recipient: message.recipient,
firstTimestamp: message.timestamp,
messageIds: immutable.List([message.id])
}));
});
} else {
return state_.updateIn(["friends", username, "messageGroups", messageGroupCount - 1, "messageIds"], (messageIds) => {
return messageIds.push(message.id);
});
}
},
scrolled: (state, action) => {
let {username, locked, scrollTop} = action;
return state.mergeIn(["friends", username], {
scrollTop: scrollTop,
scrollLocked: locked
});
}
}
};

@ -0,0 +1,27 @@
"use strict";
const types = require("../plumbing/types");
module.exports = {
name: "uiInviteFriend",
mixins: [
require("./mixins/form")({
validate: ({allow}) => {
allow();
},
process: ({formData, api}) => {
return api.createInvite({
note: formData.note
});
},
success: ({result}) => {
let invite = types.Invite(result.invite);
return [{
type: "@invites_created",
invite: invite
}];
}
})
]
};

@ -0,0 +1,81 @@
"use strict";
const immutable = require("immutable");
const types = require("../plumbing/types");
/* Additional exports:
- creationFailed
- deletionFailed
IDEA: Move the API logic away from UI modules, and into 'core modules' like this one. Then export the success/failure actions/events from the core modules, so that the UI modules can respond to them. This centralizes all logic, so that it can be called from multiple places with a single implementation.
Caveat: For UI elements to work correctly (eg. not erroneously point out error conditions that are no longer applicable), it needs to be possible to not only tie results to requests, but also to know what the latest operation of a particular type was.
*/
module.exports = {
name: "invites",
state: {
invites: immutable.Map() // string [id] -> Invite
},
stateTransformations: {
created: (state, action) => {
let {invite} = action;
return state.updateIn(["invites"], (invites) => {
return invites.set(invite.id, invite);
});
},
consumed: (state, action) => {
let {inviteId} = action;
return state.setIn(["invites", inviteId, "consumed"], true);
},
delete: (state, action) => {
let {inviteId} = action;
return state.setIn(["invites", inviteId, "beingDeleted"], true);
},
deleted: (state, action) => {
let {inviteId} = action;
return state.updateIn(["invites"], (invites) => {
return invites.delete(inviteId);
});
},
deleteFailed: (state, action) => {
/* FIXME: Don't just log to the console, but show feedback to the user, somehow... */
console.warn(`Invite delete failed: ID ${action.sourceAction.inviteId}`);
return state;
}
},
logicHandlers: {
delete: {
errorType: "deleteFailed",
validate: ({allow, reject, state, action}) => {
if (state.invites.get(action.inviteId).consumed === true) {
reject({
errorMessage: "Cannot delete invite code that has already been used"
});
} else {
allow();
}
},
process: ({action, api}) => {
return api.deleteInvite({id: action.inviteId});
},
success: ({action}) => {
return {
type: "deleted",
inviteId: action.inviteId
};
},
failure: ({error}) => {
/* FIXME: Error filtering */
return {
errorMessage: error.message
};
}
}
}
};

@ -0,0 +1,79 @@
"use strict";
const immutable = require("immutable");
function applyMessageDefaults(message) {
if (message.direction === "received") {
return message.merge({
delivered: true,
deliveredDBStored: true,
dbStored: true
});
} else {
return message;
}
}
module.exports = {
name: "messages",
state: {
messages: immutable.Map()
},
stateTransformations: {
add: (state, action) => {
let {message} = action;
return state.updateIn(["messages"], (messages) => {
return messages.set(message.id, applyMessageDefaults(message));
});
},
addFailed: (state, action) => {
/* FIXME: Eventually, attempt to re-send the message? */
console.log("failed:", action);
return state;
},
dbStored: (state, action) => {
let {messageId} = action;
return state.setIn(["messages", messageId, "dbStored"], true);
}
},
logicHandlers: {
add: {
errorType: "addFailed",
validate: ({action, reject, allow}) => {
let {message} = action;
if (message.id == null) {
reject({ errorMessage: "Message must contain an ID" });
} else {
allow();
}
},
process: ({action, dispatch, api}) => {
let {message} = action;
dispatch({
type: "@friends_addedMessageToConversation",
message: message,
username: (message.direction === "sent") ? message.recipient : message.sender
});
if (!message.dbStored) {
return api.sendMessage({message});
}
},
success: ({action}) => {
let {message} = action;
if (message.direction === "sent") {
return {
type: "dbStored",
messageId: message.id
};
}
},
failure: ({error}) => ({errorMessage: error.message})
}
}
};

@ -0,0 +1,156 @@
"use strict";
const immutable = require("immutable");
const defaultValue = require("default-value");
const types = require("../../plumbing/types");
const mapDispatch = require("../../map-dispatch");
const withArgumentActionMapper = require("../../actions/with-argument-action-mapper");
const mapActions = require("../../actions/map-actions");
function dispatchWithKey(dispatch, key) {
return mapDispatch(dispatch, (action) => {
return Object.assign({key: key}, action);
});
}
function defaultFormState(formState) {
if (formState != null) {
return formState;
} else {
return types.UiFormState();
}
}
function keyMapper(key) {
return function(action) {
return Object.assign({key: key}, action);
};
}
module.exports = function ({validate, transform, process, success}) {
return {
state: {
formState: immutable.Map() // string [key] => UiFormState
},
stateTransformations: {
processing: (state, action) => {
let {key} = action;
return state.updateIn(["formState", key], (formState) => {
let formState_ = defaultFormState(formState);
return formState_.merge({
processing: true,
errorMessage: null,
invalidFields: formState_.invalidFields.clear()
});
});
},
error: (state, action) => {
let {errorMessage, invalidFields, key} = action;
return state.updateIn(["formState", key], (formState) => {
let formState_ = defaultFormState(formState);
return formState_.merge({
processing: false,
errorMessage: errorMessage,
invalidFields: immutable.Set(invalidFields)
});
});
},
complete: (state, action) => {
let {key} = action;
return state.updateIn(["formState", key], (formState) => {
let formState_ = defaultFormState(formState);
return formState_.merge({
processing: false,
resetCounter: formState_.resetCounter + 1
});
});
},
blur: (state, action) => {
let {key} = action;
/* FIXME: Abstract out the below wrapping */
return state.updateIn(["formState", key], (formState) => {
let formState_ = defaultFormState(formState);
return formState_.merge({
wasFocused: null
});
});
},
focus: (state, action) => {
let {key, field} = action;
return state.updateIn(["formState", key], (formState) => {
let formState_ = defaultFormState(formState);
return formState_.merge({
wasFocused: field
});
});
}
},
logicHandlers: {
submit: {
errorType: "error",
validate: ({globalState, state, action, allow, reject}) => {
let validateFunc = defaultValue(validate, transform);
validateFunc({
globalState: globalState,
state: state,
formData: action.formData,
allow: withArgumentActionMapper(allow, keyMapper(action.key), {outputSingleDispatch: true}),
reject: withArgumentActionMapper(reject, keyMapper(action.key), {outputSingleDispatch: true}),
key: action.key
});
},
process: ({action, api, dispatch}) => {
let mappedDispatch = dispatchWithKey(dispatch, action.key);
mappedDispatch("processing"); /* FIXME: Is this necessary? Can't we just rename the `processing` transformation to `process` instead? */
return process({
api: api,
formData: action.formData
});
},
success: ({action, result}) => {
let successActions = success({
result: result
});
let defaultSuccessActions = [{
type: "complete"
}];
let allSuccessActions;
/* FIXME: Verify that this logic works for single (non-array) returned actions */
/* FIXME: Fail early on non-existent scoped actions, so that the error message can show the scoped name and module name instead of the globalized name. */
if (successActions != null) {
allSuccessActions = defaultSuccessActions.concat(successActions);
} else {
allSuccessActions = defaultSuccessActions;
}
return mapActions(allSuccessActions, keyMapper(action.key));
},
/* FIXME: Why did the below appear to work when the signature was still (error)=> ? */
failure: ({error, action}) => {
/* FIXME: Error filtering to distinguish bugs from end-user errors? */
console.error(error.stack);
return {
key: action.key,
errorMessage: error.message
};
}
}
}
};
}

@ -0,0 +1,89 @@
"use strict";
const nanoid = require("nanoid");
const types = require("../plumbing/types");
/* MARKER: Implement message sending */
/* TODO: Consider whether it makes sense to specify a particular action property as the key for a form. instead of having a separate key property; that would probably reduce the needed complexity of key properties being merged into actions and dispatches, and would allow for writing more semantically correct code, because it can eg. refer to `recipient: action.username` instead of `recipient: key` or even `recipient: action.key`. */
/* TODO: Make the form abstraction a little more organized; there's a lot of explicit copying of properties etc. going on right now, and it should be possibe to unify this into a single 'create mixin'-type abstraction that the form mixin can then use to make a neatly organized implementation of parameterizable form logic */
module.exports = {
name: "uiMessageInput",
mixins: [
require("./mixins/form")({
transform: ({globalState, formData, key, allow, reject}) => {
let {body} = formData;
if (body == null) {
/* No message was entered */
reject();
} else {
allow({
type: "@messages_add",
message: types.Message({
id: nanoid(),
direction: "sent",
sender: globalState.username,
recipient: key,
body: body,
timestamp: new Date()
})
});
}
},
// validate: ({globalState, formData, allow, reject}) => {
// let {body} = formData;
// if (body == null) {
// /* No message was entered */
// reject();
// } else {
// allow();
// }
// // let {username} = formData;
// // if (username == null) {
// // reject({
// // errorType: "noUsernameSpecified",
// // errorMessage: "You must specify a username.",
// // invalidFields: ["username"]
// // });
// // } else if (globalState.modules.friends.friends.has(username)) {
// // reject({
// // errorType: "friendAlreadyInList",
// // /* TODO: Make error message clearer, maybe include display name of existing entry? */
// // errorMessage: "There's already a user with that username in your friends list.",
// // invalidFields: ["username"]
// // });
// // } else {
// // allow();
// // }
// },
process: ({formData, api}) => {
return;
// return api.addFriend({
// username: formData.username
// });
},
success: ({result, action}) => {
// let friend = types.Friend(result.friend);
// return [{
// type: "@friends_added",
// friend: friend
// }, {
// type: "@friends_addedDbStored",
// username: friend.username,
// optional: true
// }, {
// type: "@selectedConversationTab",
// username: friend.username
// }];
}
})
]
};

@ -0,0 +1,121 @@
"use strict";
const Promise = require("bluebird");
const reduxLogic = require("redux-logic");
const assureArray = require("assure-array");
const assert = require("assert");
module.exports = function createLogic({type, validate, transform, process, success, failure, errorType, actionMapper = (action) => action, actionUnmapper = (action) => action, dataMapper = (data) => data}) {
assert(errorType != null);
/* FIXME: Add support for never-ending processes (ie. no done() called ever) */
/* FIXME: Implement a `transform` wrapper; at least `actionMapper` support, possible other changes also. */
/* QUESTION: Is dataMapper(unmapData(data)) the sensible order, or should it be the other way around? */
let validateFunc, processFunc;
function unmapData(data) {
return Object.assign({}, data, {
action: actionUnmapper(data.action)
});
}
if (validate != null) {
validateFunc = function (data, allow, reject) {
let originalAction = data.action;
let allowHandler = function (allowAction) {
if (allowAction != null) {
allow(actionMapper(allowAction));
} else {
allow(originalAction);
}
};
let rejectHandler = function (errorData) {
if (typeof errorData === "string") {
reject(actionMapper({
type: errorData,
sourceAction: originalAction
}));
} else {
reject(actionMapper(Object.assign({
type: errorType,
error: true,
sourceAction: originalAction
}, errorData)));
}
}
let props = Object.assign({
allow: allowHandler,
reject: rejectHandler
}, dataMapper(unmapData(data)));
validate(props);
}
}
if (process != null) {
processFunc = function (data, dispatch, done) {
let originalAction = data.action;
function dispatchWrapper(action) {
if (typeof action === "string") {
dispatch(actionMapper({type: action}));
} else {
dispatch(actionMapper(action));
}
}
function dispatchAll(items, mapper = (item) => item) {
assureArray(items).forEach((item) => {
dispatchWrapper(mapper(item));
});
}
return Promise.try(() => {
let props = Object.assign({
dispatch: dispatchWrapper
}, dataMapper(unmapData(data)));
return process(props);
}).then((result) => {
let successAction = success({
result: result,
action: originalAction
});
if (successAction != null) {
dispatchAll(successAction);
}
}).catch((error) => {
// console.error(error.message);
/* FIXME: Some sort of flag on error objects to distinguish between bugs and end-user errors? */
let errorData = failure({
error: error,
action: originalAction
});
if (errorData != null) {
dispatchAll(errorData, (item) => {
return Object.assign({
type: errorType,
error: true,
sourceAction: originalAction
}, item);
});
}
}).finally(() => {
done();
});
}
}
return reduxLogic.createLogic({
type: type,
validate: validateFunc,
transform: transform,
process: processFunc
});
};

@ -0,0 +1,320 @@
"use strict";
const redux = require("redux");
const reduxLogic = require("redux-logic");
const loadModules = require("./module-loader");
const globalStateTransformations = require("./global-state-transformations");
const globalDefaultState = require("./global-default-state");
const mockApi = require("./mock-api");
function reducer(state, action) {
// return match(action.type, {
// selectedConversationTab: () => {
// let {username} = action;
// return state.merge({
// selectedTabType: "conversation",
// selectedTab: username
// });
// },
// selectedAddFriendTab: () => {
// return state.merge({
// selectedTabType: "meta",
// selectedTab: "addFriend"
// });
// },
// uiAddFriendProcessing: () => {
// return state.updateIn(["uiAddFriend"], (formState) => {
// return formState.merge({
// processing: true,
// errorMessage: null
// });
// });
// },
// uiAddFriendError: () => {
// let {errorMessage} = action;
// return state.updateIn(["uiAddFriend"], (formState) => {
// return formState.merge({
// processing: false,
// errorMessage: errorMessage
// });
// });
// },
// uiAddFriendComplete: () => {
// return state.setIn(["uiAddFriend", "processing"], false);
// },
// connected: () => {
// let {username, displayName, statusMessage, status, friends} = action;
// return state.merge({
// connected: true,
// username: username,
// displayName: displayName,
// status: validateStatus(status),
// statusMessage: statusMessage,
// friends: immutable.Map(friends.map((friend) => {
// return [friend.username, friend];
// }))
// });
// },
// disconnected: () => {
// return state.merge({
// connected: false
// });
// },
// userChangedStatus: () => {
// let {status} = action;
// return state.set("status", validateStatus(status));
// },
// userChangedStatusMessage: () => {
// let {statusMessage} = action;
// return state.set("statusMessage", statusMessage);
// },
// userChangedDisplayName: () => {
// let {displayName} = action;
// return state.merge({
// displayName: displayName,
// displayNameDBStored: false
// });
// },
// userChangedDisplayNameDBStored: () => {
// return state.set("displayNameDBStored", true);
// },
// userStartedTyping: () => {
// let {recipient} = action;
// return state.updateIn(["isTypingTo"], (friends) => {
// return friends.add(recipient);
// });
// },
// userPausedTyping: () => {
// let {recipient} = action;
// return state.updateIn(["pausedTypingTo"], (friends) => {
// return friends.add(recipient);
// });
// },
// userResumedTyping: () => {
// let {recipient} = action;
// return state.updateIn(["pausedTypingTo"], (friends) => {
// return friends.delete(recipient);
// });
// },
// userStoppedTyping: () => {
// let {recipient} = action;
// return state.updateIn(["isTypingTo"], (friends) => {
// return friends.delete(recipient);
// });
// },
// friendChangedStatus: () => {
// let {username, status} = action;
// /* TODO: Figure out a way to express the branching below in a functional, expression-based manner. */
// let state_ = state.setIn(["friends", username, "status"], status);
// if (status === "offline") {
// return state_.update(clearFriendState(username));
// } else {
// return state_;
// }
// },
// friendChangedStatusMessage: () => {
// let {statusMessage} = action;
// return state.setIn(["friends", username, "statusMessage"], statusMessage);
// },
// friendChangedDisplayName: () => {
// let {username, displayName} = action;
// return state.setIn(["friends", username, "displayName"], displayName);
// },
// friendStartedTyping: () => {
// return state.setIn(["friends", username, "isTyping"], true);
// },
// friendPausedTyping: () => {
// return state.setIn(["friends", username, "pausedTyping"], true);
// },
// friendResumedTyping: () => {
// return state.setIn(["friends", username, "pausedTyping"], false);
// },
// friendStoppedTyping: () => {
// return state.setIn(["friends", username, "isTyping"], false);
// },
// messageReceived: () => {
// let {message} = action;
// return state
// .update(addMessage(message.sender, message.merge({
// direction: "received",
// delivered: true,
// deliveredDBStored: true
// })));
// },
// messageReceivedDBStored: () => {
// let {messageId} = action;
// return state.update(markMessageDbStored(messageId));
// },
// messageSent: () => {
// let {message} = action;
// return state
// .update(addMessage(message.recipient, message.merge({
// direction: "sent"
// })));
// },
// messageSentDBStored: () => {
// let {messageId} = action;
// return state.update(markMessageDbStored(messageId));
// },
// messageDelivered: () => {
// // Defined as at least one device of the recipient having received the message, and acknowledged as such
// let {messageId} = action;
// return state.mergeIn(["messages", messageId], {
// delivered: true,
// queuedForOfflineDelivery: false
// });
// },
// messageDeliveredDBStored: () => {
// let {messageId} = action;
// return state.setIn(["messages", messageId, "deliveredDBStored"], true);
// },
// messageQueuedForOfflineDelivery: () => {
// let {messageId} = action;
// return state.setIn(["messages", messageId, "queuedForOfflineDelivery"], true);
// },
// friendAdded: () => {
// let {friend} = action;
// return state.updateIn(["friends"], (friends) => friends.set(friend.username, friend));
// },
// friendAddedDBStored: () => {
// let {username} = action;
// return state.setIn(["friends", username, "dbStored"], true);
// },
// friendRemoved: () => {
// let {username} = action;
// return state.updateIn(["friends"], (friends) => friends.delete(username));
// },
// friendRemovedDBStored: () => {
// /* Do nothing for now, there's not really a reasonable way to represent this in the current state tree structure. */
// return state;
// },
// friendRequestReceived: () => {
// let {request} = action;
// return state.updateIn(["friendRequests"], (requests) => requests.set(request.username, request));
// },
// friendRequestWithdrawn: () => {
// let {username} = action;
// return state.update(removeFriendRequest(username));
// },
// friendRequestAccepted: () => {
// let {username} = action;
// /* NOTE: This *only* handles the `friendRequests` entry, not the addition to the friends list. That's a separate `friendAdded` action. */
// return state.update(removeFriendRequest(username));
// },
// friendRequestedAcceptedDBStored: () => {
// /* Nothing? */
// return state;
// },
// friendRequestRejected: () => {
// let {username} = action;
// return state.update(removeFriendRequest(username));
// },
// friendRequestRejectedDBStored: () => {
// /* Nothing? */
// return state;
// },
// _: () => {
// return state;
// }
// });
}
let modules = [
require("../modules/friends"),
require("../modules/add-friend"),
require("../modules/friend-requests"),
require("../modules/invites"),
require("../modules/invite-friend"),
require("../modules/messages"),
require("../modules/ui-message-input"),
];
function withDevTools(...enhancers) {
if (window != null && window.__REDUX_DEVTOOLS_EXTENSION__ != null) {
return redux.compose(...enhancers, window.__REDUX_DEVTOOLS_EXTENSION__());
} else {
return redux.compose(...enhancers);
}
/* FIXME: Check if returning `undefined` here is valid */
}
function createReducer(stateTransformations, missingActionWhitelist) {
return function reducer(state, action) {
let handler = stateTransformations[action.type];
if (handler != null) {
return handler(state, action)
} else {
/* FIXME: Log a warning here? */
if (action.type.startsWith("@@") || action.optional || missingActionWhitelist.has(action.type)) {
return state;
} else {
throw new Error(`Unknown action type: ${action.type}`);
}
}
}
}
function initializeStore({modules, logicDependencies, state}) {
let {State, stateTransformations, logicHandlers} = loadModules(modules, {
stateTransformations: globalStateTransformations,
state: state
});
let logicMiddleware = reduxLogic.createLogicMiddleware(logicHandlers, logicDependencies);
let logicEnhancer = redux.applyMiddleware(logicMiddleware);
let enhancers = withDevTools(logicEnhancer);
// let enhancers = redux.compose(logicEnhancer, createDevToolsEnhancer());
let missingActionWhitelist = new Set(logicHandlers.map((handler) => handler.type));
let reducer = createReducer(stateTransformations, missingActionWhitelist);
let initialState = State();
return redux.createStore(reducer, initialState, enhancers);
}
module.exports = {
createStore: function createStore(initialState = globalDefaultState) {
/* QUESTION: Does the default state merging order need to be swapped around so that unscoped comes last, such that the above initialState parameter can be used to initialize module state as well? */
return initializeStore({
state: initialState,
modules: modules,
logicDependencies: {
api: mockApi
}
});
}
};

@ -0,0 +1,19 @@
"use strict";
const immutable = require("immutable");
module.exports = {
connected: false, // boolean
selectedTab: null, // string
selectedTabType: null, // {"conversation", "meta"}
username: null, // string
displayName: null, // string
displayNameDBStored: true, // string
status: null, // {"online", "away", "offline"}
statusMessage: null, // string
isTypingTo: immutable.Set(), // (friends) username
pausedTypingTo: immutable.Set(), // (friends) username
// friends: immutable.Map(), // string [username] -> Friend
// friendRequests: immutable.Map(), // string [username] -> FriendRequest
// messages: immutable.Map(), // string [id] -> Message
};

@ -0,0 +1,266 @@
"use strict";
const immutable = require("immutable");
function addMessage(friendUsername, message) {
return function (state) {
return state
.updateIn(["messages"], (messages) => messages.set(message.id, message))
.updateIn(["friends", friendUsername, "messageIds"], (ids) => ids.push(message.id));
}
}
function markMessageDbStored(messageId) {
return function (state) {
return state.setIn(["messages", messageId, "dbStored"], true);
}
}
function removeFriendRequest(username) {
return function (state) {
return state.updateIn(["friendRequests"], (requests) => requests.delete(username));
}
}
function clearFriendState(username) {
return function (state) {
return state.mergeIn(["friends", username], {
isTyping: false,
pausedTyping: false
});
}
}
function validateStatus(status) {
if (!["online", "away", "offline"].includes(status)) {
throw new Error(`Invalid status: ${status}`);
} else {
return status;
}
}
function mapFromArray(sourceArray, mapper) {
return immutable.Map(sourceArray.map((item) => {
return mapper(item);
}));
}
module.exports = {
selectedConversationTab: (state, action) => {
let {username} = action;
return state.merge({
selectedTabType: "conversation",
selectedTab: username
});
},
selectedAddFriendTab: (state, action) => {
return state.merge({
selectedTabType: "meta",
selectedTab: "addFriend"
});
},
connected: (state, action) => {
let {username, displayName, statusMessage, status, friends} = action;
/* FIXME: Verify whether seeding with initial data also works for module state. */
/* TODO: Initialize Invitations, FriendRequests, etc. */
let friendMap = mapFromArray(friends, (friend) => [friend.username, friend]);
return state
.merge({
connected: true,
username: username,
displayName: displayName,
status: validateStatus(status),
statusMessage: statusMessage
})
.setIn(["modules", "friends", "friends"], friendMap);
},
disconnected: (state, action) => {
return state.merge({
connected: false
});
},
userChangedStatus: (state, action) => {
let {status} = action;
return state.set("status", validateStatus(status));
},
userChangedStatusMessage: (state, action) => {
let {statusMessage} = action;
return state.set("statusMessage", statusMessage);
},
userChangedDisplayName: (state, action) => {
let {displayName} = action;
return state.merge({
displayName: displayName,
displayNameDBStored: false
});
},
userChangedDisplayNameDBStored: (state, action) => {
return state.set("displayNameDBStored", true);
},
userStartedTyping: (state, action) => {
let {recipient} = action;
return state.updateIn(["isTypingTo"], (friends) => {
return friends.add(recipient);
});
},
userPausedTyping: (state, action) => {
let {recipient} = action;
return state.updateIn(["pausedTypingTo"], (friends) => {
return friends.add(recipient);
});
},
userResumedTyping: (state, action) => {
let {recipient} = action;
return state.updateIn(["pausedTypingTo"], (friends) => {
return friends.delete(recipient);
});
},
userStoppedTyping: (state, action) => {
let {recipient} = action;
return state.updateIn(["isTypingTo"], (friends) => {
return friends.delete(recipient);
});
},
friendChangedStatus: (state, action) => {
let {username, status} = action;
/* TODO: Figure out a way to express the branching below in a functional, expression-based manner. */
let state_ = state.setIn(["friends", username, "status"], status);
if (status === "offline") {
return state_.update(clearFriendState(username));
} else {
return state_;
}
},
friendChangedStatusMessage: (state, action) => {
let {statusMessage} = action;
return state.setIn(["friends", username, "statusMessage"], statusMessage);
},
friendChangedDisplayName: (state, action) => {
let {username, displayName} = action;
return state.setIn(["friends", username, "displayName"], displayName);
},
friendStartedTyping: (state, action) => {
return state.setIn(["friends", username, "isTyping"], true);
},
friendPausedTyping: (state, action) => {
return state.setIn(["friends", username, "pausedTyping"], true);
},
friendResumedTyping: (state, action) => {
return state.setIn(["friends", username, "pausedTyping"], false);
},
friendStoppedTyping: (state, action) => {
return state.setIn(["friends", username, "isTyping"], false);
},
messageReceived: (state, action) => {
let {message} = action;
return state
.update(addMessage(message.sender, message.merge({
direction: "received",
delivered: true,
deliveredDBStored: true
})));
},
messageReceivedDBStored: (state, action) => {
let {messageId} = action;
return state.update(markMessageDbStored(messageId));
},
messageSent: (state, action) => {
let {message} = action;
return state
.update(addMessage(message.recipient, message.merge({
direction: "sent"
})));
},
messageSentDBStored: (state, action) => {
let {messageId} = action;
return state.update(markMessageDbStored(messageId));
},
messageDelivered: (state, action) => {
// Defined as at least one device of the recipient having received the message, and acknowledged as such
let {messageId} = action;
return state.mergeIn(["messages", messageId], {
delivered: true,
queuedForOfflineDelivery: false
});
},
messageDeliveredDBStored: (state, action) => {
let {messageId} = action;
return state.setIn(["messages", messageId, "deliveredDBStored"], true);
},
messageQueuedForOfflineDelivery: (state, action) => {
let {messageId} = action;
return state.setIn(["messages", messageId, "queuedForOfflineDelivery"], true);
},
friendAdded: (state, action) => {
let {friend} = action;
return state.updateIn(["friends"], (friends) => friends.set(friend.username, friend));
},
friendAddedDBStored: (state, action) => {
let {username} = action;
return state.setIn(["friends", username, "dbStored"], true);
},
friendRemoved: (state, action) => {
let {username} = action;
return state.updateIn(["friends"], (friends) => friends.delete(username));
},
friendRemovedDBStored: (state, action) => {
/* Do nothing for now, there's not really a reasonable way to represent this in the current state tree structure. */
return state;
},
friendRequestReceived: (state, action) => {
let {request} = action;
return state.updateIn(["friendRequests"], (requests) => requests.set(request.username, request));
},
friendRequestWithdrawn: (state, action) => {
let {username} = action;
return state.update(removeFriendRequest(username));
},
friendRequestAccepted: (state, action) => {
let {username} = action;
/* NOTE: This *only* handles the `friendRequests` entry, not the addition to the friends list. That's a separate `friendAdded` action. */
return state.update(removeFriendRequest(username));
},
friendRequestedAcceptedDBStored: (state, action) => {
/* Nothing? */
return state;
},
friendRequestRejected: (state, action) => {
let {username} = action;
return state.update(removeFriendRequest(username));
},
friendRequestRejectedDBStored: (state, action) => {
/* Nothing? */
return state;
}
};

@ -0,0 +1,55 @@
"use strict";
const createLogic = require("./create-logic");
const types = require("./types");
module.exports = createLogic({
type: "uiAddFriend",
errorType: "uiAddFriendError",
validate: ({getState, action}, allow, reject) => {
if (action.username.length === 0) {
/* Error: you must enter a username */
reject({
errorType: "noUsernameSpecified",
errorMessage: "You must specify a username."
});
} else if (getState().friends.has(action.username)) {
/* Error: that user is already in your friends list */
reject({
errorType: "friendAlreadyInList",
errorMessage: "There's already a user with that username in your friends list."
});
} else {
allow();
}
},
process: ({action, api}, dispatch) => {
dispatch({
type: "uiAddFriendProcessing"
});
return api.addFriend({
username: action.username
});
},
success: (result) => {
let friend = types.Friend(result.friend);
return [{
type: "uiAddFriendComplete",
friend: friend
}, {
type: "friendAdded",
friend: friend
}, {
type: "friendAddedDbStored",
username: friend.username
}];
},
failure: (error) => {
/* FIXME: Error filtering to distinguish bugs from end-user errors? */
return {
errorMessage: error.message
};
}
});

@ -0,0 +1,62 @@
"use strict";
const Promise = require("bluebird");
const nanoidGenerate = require("nanoid/generate");
module.exports = {
addFriend: function addFriend({username}) {
return Promise.try(() => {
return Promise.delay(500);
}).then(() => {
return {
friend: {
username: username
}
};
});
},
createInvite: function createInvite({note}) {
return Promise.try(() => {
return Promise.delay(500);
}).then(() => {
let code = nanoidGenerate("2346789ABCDEFGHIJKLMNOPQRSTUVWXYZ", 18);
return {
invite: {
id: code,
code: code,
note: note
}
};
});
},
deleteInvite: function deleteInvite({id}) {
return Promise.try(() => {
return Promise.delay(500);
}).then(() => {
return {
inviteId: id
};
});
},
acceptFriendRequest: function acceptFriendRequest({username}) {
return Promise.try(() => {
return Promise.delay(500);
}).then(() => {
return {
friend: {
username: username,
displayName: `The name is ${username}`,
status: "online"
}
};
})
},
rejectFriendRequest: function rejectFriendRequest({username}) {
return Promise.delay(500);
},
sendMessage: function sendMessage({message}) {
// throw new Error("foo");
return Promise.delay(4500);
}
};

@ -0,0 +1,148 @@
"use strict";
const immutable = require("immutable");
const mapObj = require("map-obj");
const assureArray = require("assure-array");
const defaultValue = require("default-value");
const createLogic = require("./create-logic");
const scopedKey = require("./modules/scoped-key");
const unscopedKey = require("./modules/unscoped-key");
function reduceToObject(items, mapper = (item) => item) {
return items.reduce((object, item) => {
return Object.assign(object, mapper(item));
}, {});
}
function reduceToArray(items, mapper = (item) => item) {
return items.reduce((array, item) => {
return array.concat(assureArray(mapper(item)));
}, []);
}
function shallowClone(object) {
return Object.assign({}, object);
}
function resolveMixins(module) {
if (module.mixins == null) {
return {
state: defaultObject(module.state),
stateTransformations: defaultObject(module.stateTransformations),
logicHandlers: defaultObject(module.logicHandlers)
};
} else {
let moduleClone = {
state: shallowClone(defaultObject(module.state)),
stateTransformations: shallowClone(defaultObject(module.stateTransformations)),
logicHandlers: shallowClone(defaultObject(module.logicHandlers))
};
return module.mixins.reduce((resolved, mixin) => {
/* NOTE: Mutation occurs here, but we pass in a (shallow) clone as initial value. */
Object.assign(resolved.state, mixin.state);
Object.assign(resolved.stateTransformations, mixin.stateTransformations);
Object.assign(resolved.logicHandlers, mixin.logicHandlers);
return resolved;
}, moduleClone);
}
}
function resolveTopLevelModule(module) {
if (module.name == null) {
/* FIXME: Allow this in the future? */
throw new Error("Module is missing a `name` property");
}
let {state, stateTransformations, logicHandlers} = resolveMixins(module);
return {
name: module.name,
state: immutable.Record(state)(),
stateTransformations: stateTransformations,
logicHandlers: logicHandlers
};
}
function defaultObject(value) {
return defaultValue(value, {});
}
/* TODO: Maybe eventually add support for dependency specifications, so that modules can safely refer to the scope of other modules, without depending on hard-coded prefixes in identifiers? */
module.exports = function loadModules(modules, unscoped) {
let resolvedModules = modules.map((module) => resolveTopLevelModule(module));
let allModules = resolvedModules.concat([unscoped]);
let unscopedModules = allModules.filter((module) => (module.name == null));
let scopedModules = allModules.filter((module) => (module.name != null));
let stateModel = reduceToObject(unscopedModules, (module) => module.state);
let stateModelModules = reduceToObject(scopedModules, (module) => ({[module.name]: module.state}));
stateModel.modules = immutable.Record(stateModelModules)();
let State = immutable.Record(stateModel);
/* FIXME: Add support for multiple state transformations (eg. the default transformation and a module-provided one) */
let scopedStateTransformations = reduceToObject(resolvedModules, (module) => {
return mapObj(module.stateTransformations, (key, reducer) => {
return [scopedKey(module.name, key), (state, action) => {
let scopedState = reducer(state.modules[module.name], action);
return state.setIn(["modules", module.name], scopedState);
}];
});
});
let stateTransformations = Object.assign(scopedStateTransformations, unscoped.stateTransformations);
/* FIXME: Flatten array instead */
let scopedLogicHandlers = reduceToArray(resolvedModules, (module) => {
return Object.keys(module.logicHandlers).map((actionName) => {
let options = module.logicHandlers[actionName];
return createLogic({
type: scopedKey(module.name, actionName),
errorType: options.errorType, /* This is not scoped here, because it's handled in the actionMapper on dispatch */
actionMapper: (action) => {
return Object.assign({}, action, {
type: scopedKey(module.name, action.type)
});
},
actionUnmapper: (action) => {
return Object.assign({}, action, {
type: unscopedKey(module.name, action.type)
});
},
dataMapper: (data) => {
return Object.assign({}, data, {
globalState: data.getState(),
state: data.getState().modules[module.name]
});
},
validate: options.validate,
transform: options.transform,
process: options.process,
success: options.success,
failure: options.failure
});
});
});
let unscopedLogicHandlers = Object.keys(defaultValue(unscoped.logicHandlers, {})).map((actionName) => {
let options = unscoped.logicHandlers[actionName];
return createLogic(Object.assign({}, options, {
type: actionName
}));
});
let logicHandlers = scopedLogicHandlers.concat(unscopedLogicHandlers);
console.log("All registered transformations:", Object.keys(stateTransformations));
console.log("All registered logic handlers:", logicHandlers.map((handler) => handler.type));
return {
State, stateTransformations, logicHandlers
};
}

@ -0,0 +1,19 @@
"use strict";
module.exports = function scopedKey(moduleName, key) {
if (key == null) {
return key;
} else if (key.startsWith("@")) {
if (moduleName != null) {
return key.slice(1);
} else {
throw new Error("Attempted to use a global key specifier (@) in a module without a name; this is not allowed");
}
} else {
if (moduleName != null) {
return `${moduleName}_${key}`;
} else {
return key;
}
}
};

@ -0,0 +1,15 @@
"use strict";
module.exports = function unscopedKey(moduleName, scopedKey) {
if (scopedKey == null) {
return scopedKey;
} else if (moduleName == null) {
return `@${scopedKey}`;
} else {
if (scopedKey.startsWith(`${moduleName}_`)) {
return scopedKey.slice(moduleName.length + 1);
} else {
return `@${scopedKey}`
}
}
};

@ -0,0 +1,67 @@
"use strict";
const immutable = require("immutable");
let Friend = immutable.Record({
username: null, // string
displayName: null, // string
statusVisible: false, // boolean
status: null, // {"online", "away", "offline"}
statusMessage: null, // string
isTyping: false, // boolean
pausedTyping: false, // boolean
messageIds: immutable.List(), // (messages) id
messageGroups: immutable.List(), // MessageGroup
mutual: false,
scrollTop: null,
scrollLocked: null
});
let FriendRequest = immutable.Record({
/* TODO: Show more profile information here in the future, such as the display name? */
username: null, // string
message: null, // string
beingAccepted: false, // boolean
beingRejected: false, // boolean
});
let UiFormState = immutable.Record({
processing: false, // boolean
errorMessage: null, // string
invalidFields: immutable.Set(), // string
resetCounter: 0, // number
wasFocused: false, // boolean
lastValues: null, // object (key -> value)
});
let Message = immutable.Record({
id: null, // string
direction: null, // {"sent", "received"}
sender: null, // null | (friends) username
recipient: null, // null | (friends) username
timestamp: null, // Date
body: null, // string
dbStored: false, // boolean
});
let MessageGroup = immutable.Record({
id: null, // string
direction: null, // {"sent", "received"}
sender: null, // null | (friends) username
recipient: null, // null | (friends) username
firstTimestamp: null, // Date
messageIds: immutable.List() // (messages) id
})
let Invite = immutable.Record({
id: null, // string
note: null, // string
code: null, // string
consumed: false, // boolean
friendId: null, // (friends) id -- note: can become a non-existent reference over time!
beingDeleted: false
});
module.exports = {
Friend, FriendRequest, UiFormState, Message, MessageGroup, Invite
};

@ -0,0 +1,30 @@
"use strict";
const React = require("react");
const reactRedux = require("react-redux");
const scopedKey = require("./modules/scoped-key");
module.exports = function useModule(moduleName) {
let {store} = React.useContext(reactRedux.ReactReduxContext);
function getState() {
return store.getState().modules[moduleName];
}
let moduleState = getState();
if (moduleState != null) {
return {
state: moduleState,
getState: getState,
dispatch: (action) => {
store.dispatch(Object.assign({}, action, {
type: scopedKey(moduleName, action.type)
}));
}
};
} else {
throw new Error(`The specified module '${moduleName}' does not seem to exist`);
}
};

@ -0,0 +1,14 @@
"use strict";
const React = require("react");
const reactRedux = require("react-redux");
module.exports = function useStore() {
let {store} = React.useContext(reactRedux.ReactReduxContext);
return {
state: store.getState(),
getState: store.getState,
dispatch: store.dispatch
};
};

@ -0,0 +1,498 @@
$lightGridLineStyle: 1px solid rgb(207, 207, 207);
$gridLineStyle: 1px solid silver;
$freeInputBorderStyle: 1px solid rgb(212, 212, 212);
$freeInputBackground: transparent;
// $pickerHoverBackground: rgb(247, 247, 236);
$freeInputHoverBackground: transparent;
$freeInputHoverBorderStyle: 1px solid rgb(77, 77, 77);
$freeInputActiveBackground: rgb(248, 248, 246);
$freeInputActiveBorderStyle: 1px solid black;
$frameColor: rgb(241, 240, 216);
$fadedTextColor: rgb(117, 117, 117);
$friendHoverBackground: rgb(245, 245, 238);
$spinnerColor: rgba(148, 0, 0, .7);
$spinnerSize: 1em;
$messageSpinnerColor: silver;
$messageSpinnerSize: .7em;
$frameBorderRadius: 8px;
$compound2: rgb(153, 149, 61);
$compound3: rgb(255, 193, 0);
$compound4: rgb(64, 144, 255);
$compound5: rgb(20, 146, 204);
// 241, 240, 216
// 153, 149, 61
// 255, 193, 0
// 64, 144, 255
// 20, 146, 204
@mixin frame-grid {
display: grid;
grid-column-gap: .7em;
grid-row-gap: .7em;
}
@mixin free-input {
background: $freeInputBackground;
border: $freeInputBorderStyle;
&:hover {
background-color: $freeInputHoverBackground;
border: $freeInputHoverBorderStyle;
}
&:active, &:focus {
background-color: $freeInputActiveBackground;
border: $freeInputActiveBorderStyle;
}
}
@mixin free-text-input {
@include free-input;
width: 100%;
padding: .2em .5em;
border-radius: 4px;
}
@mixin selectable {
cursor: auto;
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
}
body {
/* FIXME: Fonts */
font-family: "Noto Serif", serif;
background-color: $frameColor;
margin: 0;
padding: 0;
cursor: default;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
.frame {
position: relative; /* For ensuring that .frameShadow anchors against the correct element. */
padding: .7em;
overflow: hidden;
border-radius: $frameBorderRadius;
background-color: white;
// box-shadow:
// inset 0px 1px 2px rgba(71, 71, 71, 0.384);
}
.frameShadow {
position: absolute;
z-index: 9999999;
left: 0px;
top: 0px;
right: 0px;
bottom: 0px;
pointer-events: none;
border-radius: $frameBorderRadius;
box-shadow: inset 0px 1px 2px rgba(71, 71, 71, 0.384);
}
.picker {
@include free-input;
display: grid;
grid-template-columns: 1fr auto;
border-radius: 4px;
padding: .4em;
.pickerArrow {
margin: 0 .5em;
color: rgb(99, 99, 99);
}
}
.statusBall, .statusBallPlaceholder {
display: inline-block;
width: .6em;
height: .6em;
border: .15em solid transparent;
margin: 0 .5em 0 .2em;
vertical-align: -.05em;
}
.statusBall {
border-radius: 1em;
background-color: rgb(41, 41, 41);
&.online {
background-color: rgb(9, 122, 9);
}
&.away {
background-color: rgb(190, 87, 27);
}
&.offline {
background-color: rgb(187, 187, 187);
}
}
input, textarea {
font-family: serif;
font-size: 1em;
}
form.horizontal {
input, textarea {
background: $freeInputBackground;
border: $freeInputBorderStyle;
padding: .4em .6em;
border-radius: 4px;
margin-right: .8em;
&.invalid {
background-color: rgb(255, 235, 235);
border-color: rgb(141, 0, 0);
}
}
}
.error {
padding: .7em;
color: white;
background-color: rgb(160, 0, 0);
border: 2px solid rgb(240, 29, 29);
&.form {
display: inline-block;
font-size: .9em;
margin-top: -.5em;
margin-bottom: 1em;
padding: .4em .7em;
}
}
/* The below spinner code is based from https://loading.io/css/ */
.spinner, .messageSpinner {
display: inline-block;
&:after {
content: " ";
display: block;
border-radius: 50%;
animation: spinner 1.2s linear infinite;
}
}
.spinner {
width: $spinnerSize;
height: $spinnerSize;
&:after {
width: $spinnerSize;
height: $spinnerSize;
margin: 0 .8em;
border: 3px solid $spinnerColor;
border-color: $spinnerColor transparent $spinnerColor transparent;
}
}
.messageSpinner {
width: 0;
// width: $messageSpinnerSize;
// height: $messageSpinnerSize;
height: .8em;
// margin-top: .3em;
&:after {
width: $messageSpinnerSize;
height: $messageSpinnerSize;
margin: 0 -1.5em; // TODO: This is an alignment hack.
border: 2px solid $messageSpinnerColor;
border-color: $messageSpinnerColor transparent $messageSpinnerColor transparent;
animation-duration: 3s;
}
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
table {
width: 100%;
th, td {
text-align: left;
padding: .2em .4em;
}
td {
border-top: 1px solid silver;
&.status {
button {
padding: .1em .5em;
border-width: 1px;
}
}
}
tr.consumed {
color: gray;
td.status {
font-style: italic;
font-size: .8em;
}
}
}
////////////
.app {
@include frame-grid;
grid-template-columns: 280px 1fr;
grid-template-rows: 100%;
box-sizing: border-box;
padding: .7em;
height: 100vh;
width: 100vw;
.sidebar {
@include frame-grid;
grid-template-rows: auto 1fr;
.profileCard {
display: grid;
grid-template-rows: auto auto;
.profileInformation {
border-bottom: $gridLineStyle;
padding-bottom: .5em;
margin-bottom: .5em;
.displayName {
font-size: 2em;
margin-bottom: -.2em;
}
.username {
color: rgb(70, 70, 70);
font-style: italic;
font-size: .8em;
}
}
.statusPicker {
}
.statusMessage {
margin-top: .4em;
input {
@include free-text-input;
}
}
}
.friendList {
padding: 0;
overflow-y: auto;
.friend {
padding: .8em .6em;
border-bottom: $lightGridLineStyle;
// &:last-child {
// border-bottom: none;
// }
&:hover {
background: $friendHoverBackground;
}
&.selected {
color: white;
// background-color: rgb(173, 66, 66);
font-weight: bold;
background-color: $compound5;
// background: $friendHoverBackground;
.statusBall {
border-color: rgb(204, 204, 172);
}
}
&.meta {
color: $fadedTextColor;
font-style: italic;
&.selected {
color: rgb(236, 236, 236);
}
}
}
}
}
.mainView {
display: grid;
grid-template-rows: auto 1fr;
.header {
border-bottom: $gridLineStyle;
padding-bottom: 1em;
margin-bottom: 1em;
.friendInformation {
.name {
@include selectable;
display: grid;
grid-template-columns: 1fr auto;
font-size: 2em;
.displayName {
}
.username {
color: silver;
font-style: italic;
font-size: .8em;
}
}
.statusDisplay {
display: flex;
.statusMessage {
@include selectable;
margin-left: .3em;
color: rgb(88, 88, 88);
}
}
}
.friendActions {
display: flex;
margin-top: 1em;
.action {
@include free-input;
margin: 0 .3em;
font-size: .9em;
padding: .2em .5em;
border-radius: 4px;
}
}
.headerText {
font-size: 2em;
}
}
.contents {
overflow-y: auto;
h2 {
font-weight: normal;
margin-top: 0;
}
p {
line-height: 1.5em;
margin: .7em 0;
}
&.conversation {
@include selectable;
padding: 0 1.5em;
&.locked {
// outline: 2px solid red;
}
.messageGroup {
margin-bottom: .8em;
.message {
margin-bottom: .4em;
}
.metadata {
display: flex;
border-bottom: $lightGridLineStyle;
.sender {
font-weight: bold;
flex-grow: 1;
}
.timestamp {
margin-right: .6em;
color: $fadedTextColor;
}
}
&.received {
.sender {
color: rgb(65, 65, 65);
}
}
&.sent {
.sender {
}
}
}
}
}
}
.conversationView {
@include frame-grid;
grid-template-rows: 1fr 50px;
.inputBox {
/* FIXME: Why did 100% width/height on .form not work? */
display: grid;
.form {
display: grid;
form {
// width: 100%;
// height: 100%;
display: grid;
grid-column-gap: .5em;
margin: 0;
grid-template-columns: 1fr 80px;
input, textarea {
border: none;
resize: none;
}
}
}
}
}
}

@ -0,0 +1,67 @@
"use strict";
const React = require("react");
const defaultValue = require("default-value");
module.exports = function useLockableScroll(options) {
let {onScroll} = options;
let scrollThreshold = defaultValue(options.threshold, 10);
let upstreamScrollTop = options.scrollTop;
let upstreamLocked = options.locked;
let upstreamKey = options.scrollKey;
let [localLocked, setLocalLocked] = React.useState(false);
let [ref, setRef] = React.useState();
let blockScrollEcho = React.useRef(false);
let locked = defaultValue(upstreamLocked, localLocked);
React.useLayoutEffect(() => {
// console.log("doing scroll", locked, upstreamScrollTop, content);
console.log("doing scroll; upstream locked:", upstreamLocked);
if (ref != null) {
blockScrollEcho.current = true;
if (locked === true) {
ref.scrollTop = ref.scrollHeight - ref.offsetHeight;
} else if (upstreamScrollTop != null) {
ref.scrollTop = upstreamScrollTop;
}
}
}, [upstreamKey]);
React.useEffect(() => {
/* TODO: Keep this from being called so often, even when not changing between views. */
if (ref != null) {
function handleScroll(_event) {
if (blockScrollEcho.current === true) {
/* This occurs immediately after switching views, and restoring the scroll position. There's no point in handling the scroll event resulting from our own code, so we skip it entirely. */
console.log("skipping scroll");
blockScrollEcho.current = false;
} else {
if (ref != null) {
let shouldBeLocked = ref.scrollTop > (ref.scrollHeight - ref.offsetHeight - scrollThreshold);
if (onScroll != null) {
onScroll(shouldBeLocked, ref.scrollTop);
}
setLocalLocked(shouldBeLocked);
}
}
}
ref.addEventListener("scroll", handleScroll);
return () => {
ref.removeEventListener("scroll", handleScroll);
};
}
}, [ref, onScroll]);
return [localLocked, setRef];
};

@ -0,0 +1,37 @@
"use strict";
const React = require("react");
const dateFns = require("date-fns");
function millisecondsHavePassed(date, milliseconds) {
return dateFns.isBefore(dateFns.addMilliseconds(date, milliseconds), Date.now());
}
module.exports = function useTimeout(milliseconds, baseDate = Date.now()) {
let [referenceDate, _setReferenceDate] = React.useState(baseDate);
let [timeoutHit, setTimeoutHit] = React.useState(false);
React.useEffect(() => {
let timeout;
if (millisecondsHavePassed(referenceDate, milliseconds)) {
setTimeoutHit(true);
} else {
let timeSinceReferenceDate = dateFns.differenceInMilliseconds(Date.now(), referenceDate);
let millisecondsLeft = milliseconds - timeSinceReferenceDate;
timeout = setTimeout(() => {
setTimeoutHit(true);
timeout = null;
}, millisecondsLeft);
}
return () => {
if (timeout != null) {
clearTimeout(timeout);
}
}
}, []);
return timeoutHit;
};

@ -0,0 +1,14 @@
"use strict";
const express = require("express");
const path = require("path");
let app = express();
app.use(express.static(path.join(__dirname, "../../public")));
app.get("/", (req, res, next) => {
res.send("Hello world!");
});
module.exports = app;

@ -0,0 +1,19 @@
"use strict";
module.exports = function match(value, options) {
if (options[value] != null) {
if (typeof options[value] === "function") {
return options[value]();
} else {
return options[value];
}
} else if (options._ != null) {
if (typeof options._ === "function") {
return options._();
} else {
return options._;
}
} else {
throw new Error(`No handler defined for value '${value}', and no catch-all defined either`);
}
};

@ -0,0 +1,333 @@
"use strict";
const immutable = require("immutable");
const redux = require("redux");
const match = require("./match");
let Friend = immutable.Record({
username: null, // string
displayName: null, // string
status: null, // {"online", "away", "offline"}
statusMessage: null, // string
isTyping: false, // boolean
pausedTyping: false, // boolean
messageIds: immutable.List(), // (messages) id
mutual: false
});
let FriendRequest = immutable.Record({
username: null, // string
message: null, // string
});
let UiFormState = immutable.Record({
processing: false, // boolean
errorMessage: null, // string
/* QUESTION: How to handle form clearing? We don't really want to synchronize the form state to the state tree all the time, yet otherwise it may not be possible to have the form listen to the state tree changes, because react-redux assumes that everything you connect between a component and a state tree is either a data entry in the state tree, or an action creator. */
})
let UiAddFriend = immutable.Record({
})
let State = immutable.Record({
connected: false, // boolean
selectedTab: null, // string
selectedTabType: null, // {"conversation", "meta"}
username: null, // string
displayName: null, // string
displayNameDBStored: true, // string
status: null, // {"online", "away", "offline"}
statusMessage: null, // string
isTypingTo: immutable.Set(), // (friends) username
pausedTypingTo: immutable.Set(), // (friends) username
friends: immutable.Map(), // string [username] -> Friend
friendRequests: immutable.Map(), // string [username] -> FriendRequest
messages: immutable.Map(), // string [id] -> Message
uiAddFriend: null, // UiAddFriend
});
let Message = immutable.Record({
id: null, // string
direction: null, // {"sent", "received"}
sender: null, // null | (friends) username
recipient: null, // null | (friends) username
timestamp: null, // number
body: null, // string
dbStored: false, // boolean
delivered: false, // boolean
deliveredDBStored: false, // boolean
queuedForOfflineDelivery: false, // boolean
});
function addMessage(friendUsername, message) {
return function (state) {
return state
.updateIn(["messages"], (messages) => messages.set(message.id, message))
.updateIn(["friends", friendUsername, "messageIds"], (ids) => ids.push(message.id));
}
}
function markMessageDbStored(messageId) {
return function (state) {
return state.setIn(["messages", messageId, "dbStored"], true);
}
}
function removeFriendRequest(username) {
return function (state) {
return state.updateIn(["friendRequests"], (requests) => requests.delete(username));
}
}
function clearFriendState(username) {
return function (state) {
return state.mergeIn(["friends", username], {
isTyping: false,
pausedTyping: false
});
}
}
function validateStatus(status) {
if (!["online", "away", "offline"].includes(status)) {
throw new Error(`Invalid status: ${status}`);
} else {
return status;
}
}
function reducer(state, action) {
return match(action.type, {
selectedConversationTab: () => {
let {username} = action;
return state.merge({
selectedTabType: "conversation",
selectedTab: username
});
},
selectedAddFriendTab: () => {
return state.merge({
selectedTabType: "meta",
selectedTab: "addFriend"
});
},
connected: () => {
let {username, displayName, statusMessage, status, friends} = action;
return state.merge({
connected: true,
username: username,
displayName: displayName,
status: validateStatus(status),
statusMessage: statusMessage,
friends: immutable.Map(friends.map((friend) => {
return [friend.username, friend];
}))
});
},
disconnected: () => {
return state.merge({
connected: false
});
},
userChangedStatus: () => {
let {status} = action;
return state.set("status", validateStatus(status));
},
userChangedStatusMessage: () => {
let {statusMessage} = action;
return state.set("statusMessage", statusMessage);
},
userChangedDisplayName: () => {
let {displayName} = action;
return state.merge({
displayName: displayName,
displayNameDBStored: false
});
},
userChangedDisplayNameDBStored: () => {
return state.set("displayNameDBStored", true);
},
userStartedTyping: () => {
let {recipient} = action;
return state.updateIn(["isTypingTo"], (friends) => {
return friends.add(recipient);
});
},
userPausedTyping: () => {
let {recipient} = action;
return state.updateIn(["pausedTypingTo"], (friends) => {
return friends.add(recipient);
});
},
userResumedTyping: () => {
let {recipient} = action;
return state.updateIn(["pausedTypingTo"], (friends) => {
return friends.delete(recipient);
});
},
userStoppedTyping: () => {
let {recipient} = action;
return state.updateIn(["isTypingTo"], (friends) => {
return friends.delete(recipient);
});
},
friendChangedStatus: () => {
let {username, status} = action;
/* TODO: Figure out a way to express the branching below in a functional, expression-based manner. */
let state_ = state.setIn(["friends", username, "status"], status);
if (status === "offline") {
return state_.update(clearFriendState(username));
} else {
return state_;
}
},
friendChangedStatusMessage: () => {
let {statusMessage} = action;
return state.setIn(["friends", username, "statusMessage"], statusMessage);
},
friendChangedDisplayName: () => {
let {username, displayName} = action;
return state.setIn(["friends", username, "displayName"], displayName);
},
friendStartedTyping: () => {
return state.setIn(["friends", username, "isTyping"], true);
},
friendPausedTyping: () => {
return state.setIn(["friends", username, "pausedTyping"], true);
},
friendResumedTyping: () => {
return state.setIn(["friends", username, "pausedTyping"], false);
},
friendStoppedTyping: () => {
return state.setIn(["friends", username, "isTyping"], false);
},
messageReceived: () => {
let {message} = action;
return state
.update(addMessage(message.sender, message.merge({
direction: "received",
delivered: true,
deliveredDBStored: true
})));
},
messageReceivedDBStored: () => {
let {messageId} = action;
return state.update(markMessageDbStored(messageId));
},
messageSent: () => {
let {message} = action;
return state
.update(addMessage(message.recipient, message.merge({
direction: "sent"
})));
},
messageSentDBStored: () => {
let {messageId} = action;
return state.update(markMessageDbStored(messageId));
},
messageDelivered: () => {
// Defined as at least one device of the recipient having received the message, and acknowledged as such
let {messageId} = action;
return state.mergeIn(["messages", messageId], {
delivered: true,
queuedForOfflineDelivery: false
});
},
messageDeliveredDBStored: () => {
let {messageId} = action;
return state.setIn(["messages", messageId, "deliveredDBStored"], true);
},
messageQueuedForOfflineDelivery: () => {
let {messageId} = action;
return state.setIn(["messages", messageId, "queuedForOfflineDelivery"], true);
},
friendAdded: () => {
let {friend} = action;
return state.updateIn(["friends"], (friends) => friends.set(friend.username, friend));
},
friendAddedDBStored: () => {
let {username} = action;
return state.setIn(["friends", username, "dbStored"], true);
},
friendRemoved: () => {
let {username} = action;
return state.updateIn(["friends"], (friends) => friends.delete(username));
},
friendRemovedDBStored: () => {
/* Do nothing for now, there's not really a reasonable way to represent this in the current state tree structure. */
return state;
},
friendRequestReceived: () => {
let {request} = action;
return state.updateIn(["friendRequests"], (requests) => requests.set(request.username, request));
},
friendRequestWithdrawn: () => {
let {username} = action;
return state.update(removeFriendRequest(username));
},
friendRequestAccepted: () => {
let {username} = action;
/* NOTE: This *only* handles the `friendRequests` entry, not the addition to the friends list. That's a separate `friendAdded` action. */
return state.update(removeFriendRequest(username));
},
friendRequestedAcceptedDBStored: () => {
/* Nothing? */
return state;
},
friendRequestRejected: () => {
let {username} = action;
return state.update(removeFriendRequest(username));
},
friendRequestRejectedDBStored: () => {
/* Nothing? */
return state;
},
_: () => {
return state;
}
});
}
module.exports = {
State, Message, Friend, FriendRequest,
createStore: function createStore(initialState = State()) {
let devTools;
if (window != null && window.__REDUX_DEVTOOLS_EXTENSION__ != null) {
devTools = window.__REDUX_DEVTOOLS_EXTENSION__();
}
return redux.createStore(reducer, initialState, devTools);
}
};

@ -0,0 +1,78 @@
"use strict";
const immutable = require("immutable");
const util = require("util");
const data = require("./src/shared/reducer");
let store = data.createStore();
store.dispatch({
type: "connected",
username: "joepie91",
displayName: "Sven Slootweg",
status: "online",
friends: [data.Friend({
username: "johndoe",
displayName: "John Doe",
status: "online",
mutual: true
})]
});
store.dispatch({
type: "friendAdded",
friend: data.Friend({
username: "foo"
})
});
store.dispatch({
type: "messageSent",
message: data.Message({
id: 0,
sender: "joepie91",
recipient: "johndoe",
body: "Hi yes hello",
timestamp: "22:01"
})
});
store.dispatch({
type: "messageSentDBStored",
messageId: 0
});
store.dispatch({
type: "messageDelivered",
messageId: 0
});
store.dispatch({
type: "messageDeliveredDBStored",
messageId: 0
});
store.dispatch({
type: "messageReceived",
message: data.Message({
id: 1,
sender: "johndoe",
recipient: "joepie91",
body: "hi",
timestamp: "22:03"
})
});
store.dispatch({
type: "messageSent",
message: data.Message({
id: 0,
sender: "joepie91",
recipient: "johndoe",
body: "Foo Bar",
timestamp: "22:04"
})
});
console.log(util.inspect(store.getState().toJS(), {colors: true, depth: null}));

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save