diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..bd2d190 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,33 @@ +{ + "parser": "babel-eslint", + "env": { + "es6": true, + "node": true + }, + "rules": { + "array-bracket-spacing": [2, "never"], + "comma-style": [2, "last"], + "computed-property-spacing": [2, "never"], + "default-case": 2, + "eqeqeq": 2, + "indent": [2, 2], + "max-len": [1, 100, 2], + "no-lonely-if": 2, + "no-multi-spaces": 2, + "no-multiple-empty-lines": 2, + "no-nested-ternary": 2, + "no-new-require": 2, + "no-underscore-dangle": 2, + "no-unused-expressions": 0, + "no-use-before-define": 0, + "no-var": 2, + "object-curly-spacing": [2, "always"], + "quotes": [1, "single", "avoid-escape"], + "semi": 2, + "space-after-keywords": [2, "always"], + "space-before-blocks": [2, "always"], + "space-before-function-paren": [2, "never"], + "space-before-keywords": [2, "always"], + "strict": 0 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2aa428a --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Created by https://www.gitignore.io/api/osx,node,sass + +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git +node_modules + + +### Sass ### +.sass-cache/ +*.css.map + + +### PopHub ### +build/ +dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7bf6422 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Katsuma Tanaka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a64e5a --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# PopHub + +GitHub activity & notification viewer. + +![](screenshot.png) + + +## Requirements + +Mac OS X 10.8 or later. + + +## Installation + +### Download + +You can download pre-built packages from [releases](https://github.com/questbeat/PopHub/releases) page. + + +### Build from source + + $ npm install + $ npm run dist + + +## Customization + +This is the state shape of PopHub. + + { + accessToken: '...', + user: { login: 'questbeat', ... }, + activePage: 0, // = ActivePage.EVENTS + eventsUpdateInterval: (1000 * 60 * 10), // 10 min + notificationsUpdateInterval: (1000 * 60 * 10), // 10 min + notificationsUpdateInterval: (1000 * 60 * 30), // 30 min + events: [ ... ], + fetchingEvents: false, + notifications: { + "questbeat/Foo": [ ... ], + "questbeat/Bar": [ ... ] + }, + fetchingNotifications: false, + gitHubStatus: { + "status": "good", + "last_updated": "2015-09-15T05:58:44Z" + } + } + +You can customize the behavior of app by modifying the initial state in `src/renderer/index.js`. +See the examples below. + + +### Example: Automatically sign in on launch + + const store = configureStore({ + accessToken: '...', // Write your personal access token + user: { + login: 'questbeat' + } + }); + + +### Example: Show the "Notifications" tab as default + + const store = configureStore({ + accessToken: '...', // Write your personal access token + user: { + login: 'questbeat' + }, + activePage: 1, // = ActivePage.NOTIFICATIONS + }); + + +## License + +PopHub is released under the MIT license. +For more information, see LICENSE file in this repository. diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..d2d3d08 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,111 @@ +var autoprefixer = require('gulp-autoprefixer'); +var babel = require('gulp-babel'); +var browserSync = require("browser-sync").create(); +var del = require('del'); +var eslint = require('gulp-eslint'); +var gulp = require('gulp'); +var minifyCss = require('gulp-minify-css') +var runSequence = require('run-sequence'); +var sass = require('gulp-ruby-sass'); + +gulp.task('clean', function(callback) { + del([ + 'build', + ], callback); +}); + +gulp.task('sass', function() { + return sass('./src/renderer/assets/scss/*.scss') + .on('error', function(error) { + console.error('Error:', error.message); + }) + .pipe(gulp.dest('./build/renderer/assets/css')); +}); + +gulp.task('autoprefixer', function() { + return gulp.src('./build/renderer/assets/css/*.css') + .pipe(autoprefixer({ + browsers: ['last 2 versions'], + cascade: false + })) + .pipe(gulp.dest('./build/renderer/assets/css')); +}); + +gulp.task('minify-css', function() { + return gulp.src('./build/renderer/assets/css/*.css') + .pipe(minifyCss()) + .pipe(gulp.dest('./build/renderer/assets/css')); +}); + +gulp.task('copy', function() { + return gulp.src( + [ + './src/main/assets/**/*', + './src/vendor/**/*', + './src/**/*.html' + ], + { base: 'src' } + ) + .pipe(gulp.dest('build')); +}); + +gulp.task('babelify', function() { + return gulp.src( + [ + './src/main/**/*.js', + './src/renderer/**/*.js', + './src/lib/**/*.js' + ], + { base: 'src' } + ) + .pipe(babel({ + optional: ['runtime'] + })) + .on('error', function(error) { + console.error('Error:', error.message); + this.emit('end'); + }) + .pipe(gulp.dest('build')); +}); + +gulp.task('lint', function() { + return gulp.src('./src/**/*.js') + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failOnError()); +}); + +gulp.task('browser-sync', function() { + browserSync.init({ + server: { + baseDir: './' + } + }); +}); + +gulp.task('build', function(callback) { + runSequence( + 'lint', + 'sass', + 'autoprefixer', + 'minify-css', + ['copy', 'babelify'], + callback + ); +}); + +gulp.task('watch', ['build'], function() { + gulp.watch([ + './src/**/*.js', + './src/**/*.scss', + './src/**/*.html' + ], ['build']); +}); + +gulp.task('sync', ['build', 'browser-sync'], function() { + gulp.watch(watch_paths, function() { + runSequence('build', browserSync.reload); + }); +}); + +gulp.task('default', ['build']); diff --git a/package.json b/package.json new file mode 100644 index 0000000..32e5890 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "PopHub", + "version": "0.0.1", + "description": "GitHub activity & notification viewer.", + "main": "build/main/index.js", + "scripts": { + "archive": "npm run build && bash scripts/archive.sh", + "build": "npm run clean && gulp build", + "codesign": "bash scripts/codesign.sh", + "clean": "gulp clean", + "package": "npm run build && bash scripts/package.sh", + "start": "electron .", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "pophub", + "github", + "activities", + "notification", + "mac", + "app" + ], + "author": "Katsuma Tanaka (https://github.com/questbeat)", + "repository": { + "type": "git", + "url": "https://github.com/questbeat/PopHub.git" + }, + "license": "MIT", + "dependencies": { + "babel-runtime": "^5.8.24", + "file-url": "^1.0.1", + "isomorphic-fetch": "^2.1.1", + "menubar": "https://github.com/questbeat/menubar.git", + "moment": "^2.10.6", + "react": "^0.13.3", + "react-redux": "^2.1.2", + "redux": "^3.0.0", + "redux-logger": "^1.0.8", + "redux-thunk": "^1.0.0" + }, + "devDependencies": { + "babel-eslint": "^4.1.3", + "browser-sync": "^2.9.3", + "del": "^2.0.2", + "electron-packager": "^5.1.0", + "electron-prebuilt": "^0.32.3", + "gulp": "^3.9.0", + "gulp-autoprefixer": "^3.0.1", + "gulp-babel": "^5.2.1", + "gulp-eslint": "^1.0.0", + "gulp-minify-css": "^1.2.1", + "gulp-ruby-sass": "^2.0.3", + "run-sequence": "^1.1.3" + } +} diff --git a/pophub.icns b/pophub.icns new file mode 100644 index 0000000..e019be5 Binary files /dev/null and b/pophub.icns differ diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..fa45eaa Binary files /dev/null and b/screenshot.png differ diff --git a/scripts/archive.sh b/scripts/archive.sh new file mode 100644 index 0000000..8c8c6a8 --- /dev/null +++ b/scripts/archive.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +if [ -e tmp ]; then + rm -rf tmp +fi + +mkdir -p tmp + +cp -r build tmp +cp -r node_modules tmp +cp -r package.json tmp + +pushd tmp +npm prune --production +asar pack . ../app.asar +popd + +rm -rf tmp diff --git a/scripts/codesign.sh b/scripts/codesign.sh new file mode 100644 index 0000000..2735615 --- /dev/null +++ b/scripts/codesign.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +codesign --deep --force --verbose --sign "Developer ID Application: KATSUMA TANAKA" dist/PopHub-darwin-x64/PopHub.app +codesign --verify -vvvv dist/PopHub-darwin-x64/PopHub.app +spctl -a -vvvv dist/PopHub-darwin-x64/PopHub.app diff --git a/scripts/package.sh b/scripts/package.sh new file mode 100644 index 0000000..799ee7d --- /dev/null +++ b/scripts/package.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +electron-packager . PopHub --platform=darwin --arch=x64 --version=0.32.3 --app-bundle-id=jp.questbeat.pophub-electron --app-version=0.0.1 --icon=pophub.icns --ignore="src|dist|scripts|gulpfile\\.js|pophub\\.icns|screenshot\\.png" --out=dist/ --prune --overwrite --asar diff --git a/src/lib/event-utils.js b/src/lib/event-utils.js new file mode 100644 index 0000000..7d4c2a7 --- /dev/null +++ b/src/lib/event-utils.js @@ -0,0 +1,620 @@ +import React from 'react'; + +export function octiconClassNameForEvent(event) { + switch (event.type) { + case 'CommitCommentEvent': + return 'octicon-comment-discussion'; + + case 'CreateEvent': + switch (event.payload.ref_type) { + case 'repository': + return 'octicon-repo'; + + case 'branch': + return 'octicon-git-branch'; + + case 'tag': + return 'octicon-tag'; + + default: + return ''; + } + + case 'DeleteEvent': + switch (event.payload.ref_type) { + case 'branch': + return 'octicon-git-branch'; + + case 'tag': + return 'octicon-tag'; + + default: + return ''; + } + + case 'DeploymentEvent': + return ''; + + case 'DeploymentStatusEvent': + return ''; + + case 'DownloadEvent': + return ''; + + case 'FollowEvent': + return ''; + + case 'ForkApplyEvent': + return ''; + + case 'ForkEvent': + return 'octicon-git-branch'; + + case 'GistEvent': + return ''; + + case 'GollumEvent': + return 'octicon-book'; + + case 'IssueCommentEvent': + return 'octicon-comment-discussion'; + + case 'IssuesEvent': + switch (event.payload.action) { + case 'assigned': + return ''; + + case 'unassigned': + return ''; + + case 'labeled': + return ''; + + case 'unlabeled': + return ''; + + case 'closed': + return 'octicon-issue-closed'; + + case 'opened': + return 'octicon-issue-opened'; + + case 'reopened': + return 'octicon-issue-reopened'; + + default: + return ''; + } + + case 'MemberEvent': + return ''; + + case 'PageBuildEvent': + return ''; + + case 'PublicEvent': + return 'octicon-repo'; + + case 'PullRequestEvent': + return 'octicon-git-pull-request'; + + case 'PullRequestReviewCommentEvent': + return 'octicon-comment-discussion'; + + case 'PushEvent': + return 'octicon-git-commit'; + + case 'ReleaseEvent': + return ''; + + case 'StatusEvent': + return ''; + + case 'TeamAddEvent': + return ''; + + case 'WatchEvent': + return 'octicon-star'; + + default: + return ''; + } +} + +export function titleForEvent(event) { + const actorName = event.actor.login; + const repositoryName = event.repo.name; + + switch (event.type) { + case 'CommitCommentEvent': { + const commitId = event.payload.comment.commit_id; + const commitName = `${repositoryName}@${commitId.substring(0, 10)}`; + + return `${actorName} commented on commit ${commitName}`; + } + + case 'CreateEvent': { + const refName = event.payload.ref; + + switch (event.payload.ref_type) { + case 'repository': + return `${actorName} created repository ${repositoryName}`; + + case 'branch': + return `${actorName} created branch ${refName} at ${repositoryName}`; + + case 'tag': + return `${actorName} created tag ${refName} at ${repositoryName}`; + + default: + return ''; + } + } + + case 'DeleteEvent': { + const refType = event.payload.ref_type; + const refName = event.payload.ref; + + return `${actorName} deleted ${refType} ${refName} at ${repositoryName}`; + } + + case 'DeploymentEvent': + return ''; + + case 'DeploymentStatusEvent': + return ''; + + case 'DownloadEvent': + return ''; + + case 'FollowEvent': + return ''; + + case 'ForkApplyEvent': + return ''; + + case 'ForkEvent': { + const forkeeName = event.payload.forkee.full_name; + + return `${actorName} forked ${repositoryName} to ${forkeeName}`; + } + + case 'GistEvent': + return ''; + + case 'GollumEvent': { + const pages = event.payload.pages; + + if (pages.length === 0) return ''; + + const page = pages[0]; + return `${actorName} ${page.action} the ${repositoryName} wiki`; + } + + case 'IssueCommentEvent': { + const issueNumber = event.payload.issue.number; + const issueName = `${repositoryName}#${issueNumber}`; + const isPullRequest = (event.payload.issue.pull_request !== undefined); + + if (isPullRequest) { + return `${actorName} commented on pull request ${issueName}`; + } else { + return `${actorName} commented on issue ${issueName}`; + } + } + + case 'IssuesEvent': { + const issueNumber = event.payload.issue.number; + const issueName = `${repositoryName}#${issueNumber}`; + + switch (event.payload.action) { + case 'assigned': + return ''; + + case 'unassigned': + return ''; + + case 'labeled': + return ''; + + case 'unlabeled': + return ''; + + case 'closed': + return `${actorName} closed issue ${issueName}`; + + case 'opened': + return `${actorName} opened issue ${issueName}`; + + case 'reopened': + return `${actorName} reopened issue ${issueName}`; + + default: + return ''; + } + } + + case 'MemberEvent': { + const memberName = event.payload.member.login; + + return `${actorName} added ${memberName} to ${repositoryName}`; + } + + case 'PageBuildEvent': + return ''; + + case 'PublicEvent': + return `${actorName} open sourced ${repositoryName}`; + + case 'PullRequestEvent': { + const pullRequestNumber = event.payload.number; + const pullRequestName = `${repositoryName}#${pullRequestNumber}`; + + switch (event.payload.action) { + case 'opened': + return `${actorName} opened pull request ${pullRequestName}`; + + case 'closed': + const isMerged = (event.payload.pull_request.merged === 'true'); + const closeAction = isMerged ? 'merged' : 'closed'; + + return `${actorName} ${closeAction} pull request ${pullRequestName}`; + + default: + return ''; + } + } + + case 'PullRequestReviewCommentEvent': { + const pullRequestNumber = event.payload.number; + const pullRequestName = `${repositoryName}#${pullRequestNumber}`; + + return `${actorName} commented on pull request ${pullRequestName}`; + } + + case 'PushEvent': { + const refComponents = event.payload.ref.split('/'); + const branchName = refComponents[refComponents.length - 1]; + const branchURL = `https://github.com/${repositoryName}/tree/${branchName}`; + + return `${actorName} pushed to ${branchName} at ${repositoryName}`; + } + + case 'ReleaseEvent': + return ''; + + case 'StatusEvent': + return ''; + + case 'TeamAddEvent': + return ''; + + case 'WatchEvent': + return `${actorName} starred ${repositoryName}`; + + default: + return ''; + } +} + +export function titleElementForEvent(event, onLinkClick) { + const actorName = event.actor.login; + const actorURL = `https://github.com/${actorName}`; + const repositoryName = event.repo.name; + const repositoryURL = `https://github.com/${repositoryName}`; + + switch (event.type) { + case 'CommitCommentEvent': { + const commitId = event.payload.comment.commit_id; + const commitName = `${repositoryName}@${commitId.substring(0, 10)}`; + const commitCommentURL = event.payload.comment.html_url; + + return ( +
+ {actorName} + {' commented on commit '} + {commitName} +
+ ); + } + + case 'CreateEvent': { + const refName = event.payload.ref; + const refURL = `https://github.com/${repositoryName}/tree/${refName}`; + + switch (event.payload.ref_type) { + case 'repository': + return ( +
+ {actorName} + {' created repository '} + {repositoryName} +
+ ); + + case 'branch': + return ( +
+ {actorName} + {' created branch '} + {refName} + {' at '} + {repositoryName} +
+ ); + + case 'tag': + return ( +
+ {actorName} + {' created tag '} + {refName} + {' at '} + {repositoryName} +
+ ); + + default: + return (
); + } + } + + case 'DeleteEvent': { + const refType = event.payload.ref_type; + const refName = event.payload.ref; + + return ( +
+ {actorName} + {' deleted '} + {refType} + {' '} + {refName} + {' at '} + {repositoryName} +
+ ); + } + + case 'DeploymentEvent': + return (
); + + case 'DeploymentStatusEvent': + return (
); + + case 'DownloadEvent': + return (
); + + case 'FollowEvent': + return (
); + + case 'ForkApplyEvent': + return (
); + + case 'ForkEvent': { + const forkeeName = event.payload.forkee.full_name; + const forkeeURL = `https://github.com/${forkeeName}`; + + return ( +
+ {actorName} + {' forked '} + {repositoryName} + {' to '} + {forkeeName} +
+ ); + } + + case 'GistEvent': + return (
); + + case 'GollumEvent': { + const pages = event.payload.pages; + + if (pages.length > 0) { + const page = pages[0]; + + return ( +
+ {actorName} + {' '} + {page.action} + {' the '} + {repositoryName} + {' wiki'} +
+ ); + } + + return (
); + } + + case 'IssueCommentEvent': { + const issueNumber = event.payload.issue.number; + const issueName = `${repositoryName}#${issueNumber}`; + const issueURL = event.payload.issue.html_url; + const isPullRequest = (event.payload.issue.pull_request !== undefined); + + if (isPullRequest) { + return ( +
+ {actorName} + {' commented on pull request '} + {issueName} +
+ ); + } else { + return ( +
+ {actorName} + {' commented on issue '} + {issueName} +
+ ); + } + } + + case 'IssuesEvent': { + const issueNumber = event.payload.issue.number; + const issueName = `${repositoryName}#${issueNumber}`; + const issueURL = event.payload.issue.html_url; + + switch (event.payload.action) { + case 'assigned': + return (
); + + case 'unassigned': + return (
); + + case 'labeled': + return (
); + + case 'unlabeled': + return (
); + + case 'closed': + return ( +
+ {actorName} + {' closed issue '} + {issueName} +
+ ); + + case 'opened': + return ( +
+ {actorName} + {' opened issue '} + {issueName} +
+ ); + + case 'reopened': + return ( +
+ {actorName} + {' reopened issue '} + {issueName} +
+ ); + + default: + return (
); + } + } + + case 'MemberEvent': { + const memberName = event.payload.member.login; + const memberURL = `https://github.com/${memberName}`; + + return ( +
+ {actorName} + {' added '} + {memberName} + {' to '} + {repositoryName} +
+ ); + } + + case 'PageBuildEvent': + return (
); + + case 'PublicEvent': + return ( +
+ {actorName} + {' open sourced '} + {repositoryName} +
+ ); + + case 'PullRequestEvent': { + const pullRequestNumber = event.payload.number; + const pullRequestName = `${repositoryName}#${pullRequestNumber}`; + const pullRequestURL = `https://github.com/${repositoryName}/pull/${pullRequestNumber}`; + + switch (event.payload.action) { + case 'opened': + return ( +
+ {actorName} + {' opened pull request '} + {pullRequestName} +
+ ); + + case 'closed': + const isMerged = (event.payload.pull_request.merged === 'true'); + const closeAction = isMerged ? 'merged' : 'closed'; + + return ( +
+ {actorName} + {' '} + {closeAction} + {' pull request '} + {pullRequestName} +
+ ); + + default: + return (
); + } + } + + case 'PullRequestReviewCommentEvent': { + const pullRequestNumber = event.payload.number; + const pullRequestName = `${repositoryName}#${pullRequestNumber}`; + const pullRequestURL = `https://github.com/${repositoryName}/pull/${pullRequestNumber}`; + + return ( +
+ {actorName} + {' commented on pull request '} + {pullRequestName} +
+ ); + } + + case 'PushEvent': { + const refComponents = event.payload.ref.split('/'); + const branchName = refComponents[refComponents.length - 1]; + const branchURL = `https://github.com/${repositoryName}/tree/${branchName}`; + + return ( +
+ {actorName} + {' pushed to '} + {branchName} + {' at '} + {repositoryName} +
+ ); + } + + case 'ReleaseEvent': + return (
); + + case 'StatusEvent': + return (
); + + case 'TeamAddEvent': + return (
); + + case 'WatchEvent': + return ( +
+ {actorName} + {' starred '} + {repositoryName} +
+ ); + + default: + return (
); + } +} diff --git a/src/lib/github-client.js b/src/lib/github-client.js new file mode 100644 index 0000000..e6e038f --- /dev/null +++ b/src/lib/github-client.js @@ -0,0 +1,54 @@ +import fetch from 'isomorphic-fetch'; + +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response; + } else { + const error = new Error(response.statusText); + error.response = response; + throw error; + } +} + +function parseJSON(response) { + return response.json(); +} + +export default class GitHubClient { + get(url) { + let headers = { + 'Accept': 'application/json' + }; + + if (this.accessToken) { + headers['Authorization'] = `token ${this.accessToken}`; + } + + return fetch(url, { + method: 'get', + headers: headers + }) + .then(checkStatus) + .then(parseJSON); + } + + post(url, params = {}) { + let headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + if (this.accessToken) { + headers['Authorization'] = `token ${this.accessToken}`; + } + + return fetch(url, { + method: 'post', + headers: headers, + body: JSON.stringify(params) + }) + .then(checkStatus) + .then(parseJSON); + } + +} diff --git a/src/lib/notification-utils.js b/src/lib/notification-utils.js new file mode 100644 index 0000000..10de853 --- /dev/null +++ b/src/lib/notification-utils.js @@ -0,0 +1,26 @@ +export function notificationHtmlUrl(notification) { + const urlObject = new URL(notification.subject.url); + const pathComponents = urlObject.pathname.split('/'); + const htmlUrl = notification.repository.html_url; + + switch (notification.subject.type) { + case 'Commit': + const commitSha = pathComponents[pathComponents.length - 1]; + return `${htmlUrl}/commit/${commitSha}`; + + case 'Issue': + const issueId = pathComponents[pathComponents.length - 1]; + return `${htmlUrl}/issues/${issueId}`; + + case 'PullRequest': + const pullRequestId = pathComponents[pathComponents.length - 1]; + return `${htmlUrl}/pull/${pullRequestId}`; + + case 'Release': + const tag = notification.subject.title.split(':')[0]; + return `${htmlUrl}/tag/${tag}`; + + default: + return htmlUrl; + } +} diff --git a/src/main/assets/img/status_icon.png b/src/main/assets/img/status_icon.png new file mode 100644 index 0000000..07967da Binary files /dev/null and b/src/main/assets/img/status_icon.png differ diff --git a/src/main/assets/img/status_icon@2x.png b/src/main/assets/img/status_icon@2x.png new file mode 100644 index 0000000..2177a2e Binary files /dev/null and b/src/main/assets/img/status_icon@2x.png differ diff --git a/src/main/controllers/authentication_window_controller.js b/src/main/controllers/authentication_window_controller.js new file mode 100644 index 0000000..5e9b44a --- /dev/null +++ b/src/main/controllers/authentication_window_controller.js @@ -0,0 +1,61 @@ +import BrowserWindow from 'browser-window'; +import WindowController from './window_controller'; +import GitHubClient from '../../lib/github-client'; + +export default class AuthenticationWindowController extends WindowController { + constructor({ clientId, clientSecret }) { + const window = new BrowserWindow({ + width: 800, + height: 600, + title: 'Authorize', + 'node-integration': false + }); + + super(window); + + const handleCallback = (event, url) => { + let matches; + if (matches = url.match(/\?code=([^&]*)/)) { + const code = matches[1]; + + event.preventDefault(); + + this.requestAccessToken({ + clientId: clientId, + clientSecret: clientSecret, + code: code + }); + } + }; + + window.webContents.on('will-navigate', (event, url) => { + handleCallback(event, url); + }); + + window.webContents.on('did-get-redirect-request', (event, oldUrl, newUrl, isMainFrame) => { + handleCallback(event, newUrl); + }); + + const scopes = ['notifications']; + const query = `client_id=${clientId}&scope=${scopes.join()}`; + const url = `https://github.com/login/oauth/authorize?${query}`; + window.loadUrl(url); + } + + requestAccessToken({ clientId, clientSecret, code }) { + const client = new GitHubClient(); + + client.post('https://github.com/login/oauth/access_token', { + client_id: clientId, + client_secret: clientSecret, + code: code + }) + .then(data => { + const accessToken = data.access_token; + this.emit('authentication-succeeded', accessToken); + }) + .catch(error => { + this.emit('authentication-failed', error); + }); + } +} diff --git a/src/main/controllers/popup_window_controller.js b/src/main/controllers/popup_window_controller.js new file mode 100644 index 0000000..c861491 --- /dev/null +++ b/src/main/controllers/popup_window_controller.js @@ -0,0 +1,22 @@ +import BrowserWindow from 'browser-window'; +import fileUrl from 'file-url'; +import path from 'path'; +import WindowController from './window_controller'; + +export default class PopupWindowController extends WindowController { + constructor() { + const window = new BrowserWindow({ + width: 340, + height: 480, + frame: false, + resizable: false, + transparent: true, + show: false + }); + window.setVisibleOnAllWorkspaces(true); + + super(window); + + window.loadUrl(fileUrl(path.join(__dirname, '../../renderer/index.html'))); + } +} diff --git a/src/main/controllers/window_controller.js b/src/main/controllers/window_controller.js new file mode 100644 index 0000000..dfc8180 --- /dev/null +++ b/src/main/controllers/window_controller.js @@ -0,0 +1,17 @@ +import { EventEmitter } from 'events'; + +export default class WindowController extends EventEmitter { + constructor(window) { + super(); + + this.window = window; + } + + showWindow() { + this.window.show(); + } + + hideWindow() { + this.window.hide(); + } +} diff --git a/src/main/index.js b/src/main/index.js new file mode 100644 index 0000000..8de7b44 --- /dev/null +++ b/src/main/index.js @@ -0,0 +1,72 @@ +import app from 'app'; +import BrowserWindow from 'browser-window'; +import dialog from 'dialog'; +import fileUrl from 'file-url'; +import ipc from 'ipc'; +import Menubar from 'menubar'; +import path from 'path'; +import AuthenticationWindowController from './controllers/authentication_window_controller'; +import PopupWindowController from './controllers/popup_window_controller'; + +let pwc = null; +let awc = null; +let timerIds = {}; + +const menubar = Menubar({ + icon_path: path.join(__dirname, 'assets/img/status_icon.png') +}); + +menubar.on('ready', () => { + pwc = new PopupWindowController(); + menubar.setWindow(pwc.window); + + ipc.on('authenticate', () => { + awc = new AuthenticationWindowController({ + clientId: '', + clientSecret: '' + }); + + awc.on('authentication-succeeded', (accessToken) => { + awc.hideWindow(); + awc = null; + + pwc.window.webContents.send('authentication-succeeded', accessToken); + }); + + awc.on('authentication-failed', (error) => { + awc.hideWindow(); + awc = null; + + dialog.showErrorBox('Error', error.message); + }); + }); + + ipc.on('show-popup-window', () => { + pwc.showWindow(); + }); + + ipc.on('start-auto-update-timer', (e, key, interval) => { + startAutoUpdateTimer(key, interval); + }); + + ipc.on('stop-auto-update-timer', (e, key) => { + stopAutoUpdateTimer(key); + }); + + function startAutoUpdateTimer(key, interval) { + stopAutoUpdateTimer(key); + + if (interval > 0) { + timerIds[key] = setInterval(() => { + pwc.window.webContents.send('auto-update-timer-fired', key); + }, interval); + } + } + + function stopAutoUpdateTimer(key) { + if (timerIds[key]) { + clearInterval(timerIds[key]); + delete timerIds[key]; + } + } +}); diff --git a/src/renderer/actions.js b/src/renderer/actions.js new file mode 100644 index 0000000..1e9254f --- /dev/null +++ b/src/renderer/actions.js @@ -0,0 +1,320 @@ +import ipc from 'ipc'; +import shell from 'shell'; +import remote from 'remote'; +import { titleForEvent } from '../lib/event-utils'; +import { notificationHtmlUrl } from '../lib/notification-utils'; +import GitHubClient from '../lib/github-client'; +const dialog = remote.require('dialog'); + +export const SET_ACCESS_TOKEN = 'SET_ACCESS_TOKEN'; +export const SET_USER = 'SET_USER'; +export const SET_ACTIVE_PAGE = 'SET_ACTIVE_PAGE'; +export const SET_EVENTS_UPDATE_INTERVAL = 'SET_EVENTS_UPDATE_INTERVAL'; +export const SET_NOTIFICATIONS_UPDATE_INTERVAL = 'SET_NOTIFICATIONS_UPDATE_INTERVAL'; +export const SET_GITHUB_STATUS_UPDATE_INTERVAL = 'SET_GITHUB_STATUS_UPDATE_INTERVAL'; +export const REQUEST_EVENTS = 'REQUEST_EVENTS'; +export const RECEIVE_EVENTS = 'RECEIVE_EVENTS'; +export const CLEAR_EVENTS = 'CLEAR_EVENTS'; +export const REQUEST_NOTIFICATIONS = 'REQUEST_NOTIFICATIONS'; +export const RECEIVE_NOTIFICATIONS = 'RECEIVE_NOTIFICATIONS'; +export const CLEAR_NOTIFICATIONS = 'CLEAR_NOTIFICATIONS'; +export const MARK_NOTIFICATION_AS_READ = 'MARK_NOTIFICATION_AS_READ'; +export const MARK_NOTIFICATIONS_AS_READ_IN_REPOSITORY = 'MARK_NOTIFICATIONS_AS_READ_IN_REPOSITORY'; +export const FETCH_GITHUB_STATUS = 'FETCH_GITHUB_STATUS'; +export const CLEAR_GITHUB_STATUS = 'CLEAR_GITHUB_STATUS'; + +export const ActivePage = { + EVENTS: 0, + NOTIFICATIONS: 1 +}; + +export function setAccessToken(accessToken) { + return { + type: SET_ACCESS_TOKEN, + accessToken: accessToken + }; +} + +export function setUser(user) { + return { + type: SET_USER, + user: user + }; +} + +export function setActivePage(activePage) { + return { + type: SET_ACTIVE_PAGE, + activePage: activePage + }; +} + +export function setEventsUpdateInterval(eventsUpdateInterval) { + return { + type: SET_EVENTS_UPDATE_INTERVAL, + eventsUpdateInterval: eventsUpdateInterval + }; +} + +export function setNotificationsUpdateInterval(notificationsUpdateInterval) { + return { + type: SET_NOTIFICATIONS_UPDATE_INTERVAL, + notificationsUpdateInterval: notificationsUpdateInterval + }; +} + +export function setGitHubStatusUpdateInterval(gitHubStatusUpdateInterval) { + return { + type: SET_GITHUB_STATUS_UPDATE_INTERVAL, + gitHubStatusUpdateInterval: gitHubStatusUpdateInterval + }; +} + +function requestEvents() { + return { + type: REQUEST_EVENTS + }; +} + +function receiveEvents(events) { + return { + type: RECEIVE_EVENTS, + events: events, + receivedAt: Date.now() + }; +} + +export function clearEvents() { + return { + type: CLEAR_EVENTS + }; +} + +function shouldFetchEvents(state) { + if (!state.events) { + return true; + } else { + return !state.fetchingEvents; + } +} + +function getEventsDiff(oldEvents, newEvents) { + if (oldEvents.length === 0) return newEvents; + + const newIds = newEvents.map((event) => { + return event.id; + }); + const index = newIds.indexOf(oldEvents[0].id); + + if (index !== -1) { + return newEvents.slice(0, index); + } else { + return newEvents; + } +} + +function fetchEvents() { + return (dispatch, getState) => { + const { accessToken, user, events } = getState(); + if (accessToken === '') return; + + const client = new GitHubClient(); + client.accessToken = accessToken; + + dispatch(requestEvents()); + + client.get(`https://api.github.com/users/${user.login}/received_events?per_page=100`) + .then(newEvents => { + const diff = getEventsDiff(events, newEvents); + + // Send desktop notification if necessary + if (!remote.getCurrentWindow().isVisible()) { + if (diff.length === 1) { + const event = diff[0]; + const notification = new Notification(event.repo.name, { + body: titleForEvent(event) + }); + notification.onclick = () => { + ipc.send('show-popup-window'); + }; + } else if (diff.length > 0) { + const notification = new Notification('PopHub', { + body: `You have ${diff.length} new events.` + }); + notification.onclick = () => { + ipc.send('show-popup-window'); + }; + } + } + + dispatch(receiveEvents(diff)); + }) + .catch(error => { + dialog.showErrorBox('Error', error.message); + }); + }; +} + +export function fetchEventsIfNeeded() { + return (dispatch, getState) => { + if (shouldFetchEvents(getState())) { + return dispatch(fetchEvents()); + } + }; +} + +function requestNotifications() { + return { + type: REQUEST_NOTIFICATIONS + }; +} + +function receiveNotifications(notifications) { + return { + type: RECEIVE_NOTIFICATIONS, + notifications: notifications, + receivedAt: Date.now() + }; +} + +export function clearNotifications() { + return { + type: CLEAR_NOTIFICATIONS + }; +} + +function parseNotifications(notifications) { + const sections = {}; + + for (const notification of notifications) { + const key = notification['repository']['full_name']; + + if (key in sections) { + sections[key].push(notification); + } else { + sections[key] = [notification]; + } + } + + return sections; +} + +function getNotificationsDiff(oldNotifications, newNotifications) { + const oldIds = []; + Object.keys(oldNotifications).forEach(key => { + for (const notification of oldNotifications[key]) { + oldIds.push(notification.id); + } + }); + + const diff = []; + Object.keys(newNotifications).forEach(key => { + for (const notification of newNotifications[key]) { + if (oldIds.indexOf(notification.id) === -1) { + diff.push(notification); + } + } + }); + + return diff; +} + +export function fetchNotifications() { + return (dispatch, getState) => { + const { accessToken, notifications } = getState(); + if (accessToken === '') return; + + const client = new GitHubClient(); + client.accessToken = accessToken; + + dispatch(requestNotifications()); + + client.get('https://api.github.com/notifications') + .then(data => { + const newNotifications = parseNotifications(data); + const diff = getNotificationsDiff(notifications, newNotifications); + + // Send desktop notification if necessary + if (!remote.getCurrentWindow().isVisible()) { + if (diff.length === 1) { + const item = diff[0]; + const notification = new Notification(item.repository.name, { + body: item.subject.title + }); + notification.onclick = () => { + dispatch(markNotificationAsRead(item)); + shell.openExternal(notificationHtmlUrl(item)); + }; + } else if (diff.length > 0) { + const notification = new Notification('PopHub', { + body: `You have ${diff.length} unread notifications.` + }); + notification.onclick = () => { + ipc.send('show-popup-window'); + }; + } + } + + dispatch(receiveNotifications(newNotifications)); + }) + .catch(error => { + dialog.showErrorBox('Error', error.message); + }); + }; +} + +function shouldFetchNotifications(state) { + if (!state.notifications) { + return true; + } else { + return !state.fetchingNotifications; + } +} + +export function fetchNotificationsIfNeeded() { + return (dispatch, getState) => { + if (shouldFetchNotifications(getState())) { + return dispatch(fetchNotifications()); + } + }; +} + +export function markNotificationAsRead(notification) { + return { + type: MARK_NOTIFICATION_AS_READ, + notification: notification + }; +} + +export function markNotificationsAsReadInRepository(repository) { + return { + type: MARK_NOTIFICATIONS_AS_READ_IN_REPOSITORY, + repository: repository + }; +} + +export function fetchGitHubStatus() { + return (dispatch, getState) => { + const { accessToken } = getState(); + if (accessToken === '') return; + + const client = new GitHubClient(); + + client.get('https://status.github.com/api/status.json') + .then(data => { + dispatch({ + type: FETCH_GITHUB_STATUS, + gitHubStatus: data + }); + }) + .catch(error => { + dialog.showErrorBox('Error', error.message); + }); + }; +} + +export function clearGitHubStatus() { + return { + type: CLEAR_GITHUB_STATUS + }; +} diff --git a/src/renderer/assets/scss/_balloon.scss b/src/renderer/assets/scss/_balloon.scss new file mode 100644 index 0000000..d585353 --- /dev/null +++ b/src/renderer/assets/scss/_balloon.scss @@ -0,0 +1,36 @@ +.balloon { + position: relative; + width: 100%; + padding: 10px; + background-color: $balloon-background-color; + border-radius: 8px; + font-size: 0.8rem; + box-shadow: 0px 2px 6px 2px $balloon-shadow-color; + + &:before { + content: ""; + position: absolute; + top: -$arrow-size / 2; + left: 50%; + margin-left: -$arrow-size / 2; + margin-bottom: 10px; + width: $arrow-size; + height: $arrow-size / 2; + background-color: $balloon-background-color; + box-shadow: 0px 2px 10px 4px $balloon-shadow-color; + z-index: -1; + } + + &:after { + content: ""; + position: absolute; + top: -$arrow-size; + left: 50%; + margin-left: -$arrow-size; + width: 0; + height: 0; + border-bottom: $arrow-size solid $balloon-background-color; + border-left: $arrow-size solid transparent; + border-right: $arrow-size solid transparent; + } +} diff --git a/src/renderer/assets/scss/_base.scss b/src/renderer/assets/scss/_base.scss new file mode 100644 index 0000000..09cb08a --- /dev/null +++ b/src/renderer/assets/scss/_base.scss @@ -0,0 +1,110 @@ +html { + box-sizing: border-box; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +html { + font-size: 16px; +} + +body { + margin: 0; + padding: 18px 10px 0; + + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1rem; + line-height: 1.5; + color: $text-color; + background-color: transparent; + + overflow: hidden; + word-wrap: break-word; + overflow-wrap: break-word; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} + +h1 { + font-size: 1.3rem; +} + +h2 { + font-size: 1.2rem; +} + +h3 { + font-size: 1.1rem; +} + +p { + margin: 0; +} + +a { + color: $link-color; + text-decoration: none; +} + +a:focus, +a:hover { + color: $link-color; + text-decoration: underline; +} + +a:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} + +.center-block { + display: block; + margin-left: auto; + margin-right: auto; +} + +.pull-right { + float: right !important; +} + +.pull-left { + float: left !important; +} + +.text-primary { + color: $primary-color; +} + +.text-danger { + color: $danger-color; +} + +.text-warning { + color: $warning-color; +} + +.text-success { + color: $success-color; +} + +.text-info { + color: $info-color; +} + +.text-muted { + color: $muted-color; +} + +.text-capitalize { + text-transform: capitalize; +} diff --git a/src/renderer/assets/scss/_button-group.scss b/src/renderer/assets/scss/_button-group.scss new file mode 100644 index 0000000..b94a128 --- /dev/null +++ b/src/renderer/assets/scss/_button-group.scss @@ -0,0 +1,43 @@ +.btn-group { + position: relative; + display: inline-block; + vertical-align: middle; + + > .btn { + position: relative; + float: left; + + &:hover, &:focus, &:active, &.active { + z-index: 2; + } + } +} + +.btn-group { + .btn + .btn, + .btn + .btn-group, + .btn-group + .btn, + .btn-group + .btn-group { + margin-left: -1px; + } +} + +.btn-group > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} + +.btn-group > .btn:first-child { + margin-left: 0; + + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +} + +.btn-group > .btn:last-child:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group-sm > .btn { @extend .btn-sm; } diff --git a/src/renderer/assets/scss/_buttons.scss b/src/renderer/assets/scss/_buttons.scss new file mode 100644 index 0000000..acb41f7 --- /dev/null +++ b/src/renderer/assets/scss/_buttons.scss @@ -0,0 +1,85 @@ +.btn { + display: inline-block; + padding: 4px; + font-size: 1rem; + font-weight: normal; + line-height: 1.5; + text-align: center; + white-space: nowrap; + vertical-align: middle; + cursor: pointer; + user-select: none; + border: 1px solid transparent; + border-radius: 4px; + + &, &:active, &.active { + &:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; + } + } + + &:hover, &:focus { + text-decoration: none; + } + + &:active, &.active { + background-image: none; + outline: 0; + } + + &.disabled, &:disabled { + cursor: not-allowed; + opacity: .65; + } +} + +a.btn.disabled { + pointer-events: none; +} + +.btn-primary-outline { + color: $primary-color; + background-color: transparent; + background-image: none; + border-color: $primary-color; + + &:hover, &:focus, &:active, &.active { + color: #fff; + background-color: $primary-color; + border-color: $primary-color; + } + + &:disabled, &.disabled { + &:hover, &:focus { + border-color: $primary-color; + } + } +} + +.btn-danger-outline { + color: $danger-color; + background-color: transparent; + background-image: none; + border-color: $danger-color; + + &:hover, &:focus, &:active, &.active { + color: #fff; + background-color: $danger-color; + border-color: $danger-color; + } + + &:disabled, &.disabled { + &:hover, &:focus { + border-color: $danger-color; + } + } +} + +.btn-sm, .btn-group-sm > .btn { + padding: 2px; + font-size: 0.85rem; + line-height: 1.5; + border-radius: 4px; +} diff --git a/src/renderer/assets/scss/_events-page.scss b/src/renderer/assets/scss/_events-page.scss new file mode 100644 index 0000000..bc3f89f --- /dev/null +++ b/src/renderer/assets/scss/_events-page.scss @@ -0,0 +1,53 @@ +.events-page { + height: 380px; + overflow: scroll; + font-size: 0.8rem; + + a:active, a:hover, a:link, a:visited { + color: $link-color; + } + + .event { + padding: 4px 6px; + border-bottom: 1px solid $event-border-color; + + .event-icon { + min-width: 21px; + padding: 2px 6px 0 0; + display: table-cell; + color: $event-icon-color; + } + + .event-body { + display: table-cell; + + .event-title { + margin: 0; + padding: 0; + word-break: break-all; + } + + .event-date { + font-size: 0.75rem; + color: $event-date-color; + } + } + + &:last-child { + border-bottom: none; + } + } + + .no-events { + padding-top: 140px; + text-align: center; + + .mega-octicon { + color: #aaaaaa; + } + + h2 { + margin-top: 8px; + } + } +} diff --git a/src/renderer/assets/scss/_footer.scss b/src/renderer/assets/scss/_footer.scss new file mode 100644 index 0000000..c42f584 --- /dev/null +++ b/src/renderer/assets/scss/_footer.scss @@ -0,0 +1,23 @@ +.footer { + padding: 8px 0 0; + overflow: scroll; + border-top: 1px solid $balloon-border-color; + font-size: 0.8rem; + + .settings-icon-container { + a:active, a:hover, a:link, a:visited { + color: $text-color; + } + } + + .settings-icon-gear { + padding-right: 2px; + font-size: 22px; + vertical-align: middle; + } + + .settings-icon-arrow { + font-size: 12px; + vertical-align: middle; + } +} diff --git a/src/renderer/assets/scss/_header.scss b/src/renderer/assets/scss/_header.scss new file mode 100644 index 0000000..fc41c0c --- /dev/null +++ b/src/renderer/assets/scss/_header.scss @@ -0,0 +1,16 @@ +.header { + border-bottom: 1px solid $balloon-border-color; + + .header-button-container { + padding-bottom: 10px; + display: flex; + + .btn { + width: 100%; + } + + .btn-sm { + padding: 0.11rem 0.75rem; + } + } +} diff --git a/src/renderer/assets/scss/_notifications-page.scss b/src/renderer/assets/scss/_notifications-page.scss new file mode 100644 index 0000000..e2398d0 --- /dev/null +++ b/src/renderer/assets/scss/_notifications-page.scss @@ -0,0 +1,81 @@ +.notifications-page { + height: 380px; + overflow: scroll; + font-size: 0.8rem; + + .notification-section { + .notification-section-title { + padding: 0 12px 0 8px; + overflow: hidden; + vertical-align: middle; + background-color: rgb(238, 238, 238); + border-top: 1px solid $notification-border-color; + border-bottom: 1px solid $notification-border-color; + + .checkmark { + margin: auto 0; + font-size: 20px; + } + } + + .notifications { + .notification { + padding: 4px 6px; + border-bottom: 1px solid $notification-border-color; + + .notification-icon { + min-width: 21px; + padding: 2px 6px 0 0; + display: table-cell; + color: $notification-icon-color; + } + + .notification-body { + display: table-cell; + + .notification-title { + margin: 0; + padding: 0; + } + + .notification-date { + font-size: 0.75rem; + color: $notification-date-color; + } + } + + &:hover { + background-color: rgb(248, 248, 248); + cursor: pointer; + } + + &:last-child { + border-bottom: none; + } + } + } + + a:active, a:hover, a:link, a:visited { + color: $text-color; + } + + &:first-child { + .notification-section-title { + border-top: none; + } + } + } + + .no-notifications { + padding-top: 140px; + text-align: center; + + .mega-octicon { + color: #aaaaaa; + } + + h2 { + margin-top: 8px; + } + } +} diff --git a/src/renderer/assets/scss/_sign-in-page.scss b/src/renderer/assets/scss/_sign-in-page.scss new file mode 100644 index 0000000..dfe6246 --- /dev/null +++ b/src/renderer/assets/scss/_sign-in-page.scss @@ -0,0 +1,22 @@ +.sign-in-page { + font-size: 0.9rem; + + h1 { + margin: 0 0 8px; + } + + p { + margin: 0 0 12px; + } + + .button-container { + display: flex; + flex-direction: row; + justify-content: space-between; + + .btn { + width: 140px; + font-size: 0.9rem; + } + } +} diff --git a/src/renderer/assets/scss/_variables.scss b/src/renderer/assets/scss/_variables.scss new file mode 100644 index 0000000..7c48fbc --- /dev/null +++ b/src/renderer/assets/scss/_variables.scss @@ -0,0 +1,21 @@ +$text-color: #373a3c; +$link-color: #4183c4; +$primary-color: #1887fe; +$danger-color: #ec5d57; +$warning-color: #f0ad4e; +$success-color: #5cb85c; +$info-color: #5bc0de; +$muted-color: #818a91; + +$balloon-background-color: #fff; +$balloon-shadow-color: rgba(0, 0, 0, 0.2); +$balloon-border-color: #d0d2d4; +$arrow-size: 12px; + +$event-icon-color: #b2b8bc; +$event-border-color: #f0f0f4; +$event-date-color: #a8aeb2; + +$notification-icon-color: #373a3c; +$notification-border-color: #d0d2d4; +$notification-date-color: #a8aeb2; diff --git a/src/renderer/assets/scss/style.scss b/src/renderer/assets/scss/style.scss new file mode 100644 index 0000000..afa556c --- /dev/null +++ b/src/renderer/assets/scss/style.scss @@ -0,0 +1,10 @@ +@import "variables"; +@import "base"; +@import "buttons"; +@import "button-group"; +@import "balloon"; +@import "header"; +@import "footer"; +@import "sign-in-page"; +@import "events-page"; +@import "notifications-page"; diff --git a/src/renderer/components/event.js b/src/renderer/components/event.js new file mode 100644 index 0000000..1c8eea8 --- /dev/null +++ b/src/renderer/components/event.js @@ -0,0 +1,46 @@ +import React, { Component, PropTypes } from 'react'; +import moment from 'moment'; +import { + octiconClassNameForEvent, + titleElementForEvent +} from '../../lib/event-utils'; + +export default class Event extends Component { + render() { + const { + event, + onLinkClick + } = this.props; + + const octiconClassName = octiconClassNameForEvent(event); + let eventIconElement; + if (octiconClassName === '') { + eventIconElement = ( + + ); + } else { + eventIconElement = ( + + ); + } + + return ( +
+ {eventIconElement} +
+

+ {titleElementForEvent(event, onLinkClick).props.children} +

+

+ {moment(event.created_at).fromNow()} +

+
+
+ ); + } +} + +Event.propTypes = { + event: PropTypes.object.isRequired, + onLinkClick: PropTypes.func.isRequired +}; diff --git a/src/renderer/components/events.js b/src/renderer/components/events.js new file mode 100644 index 0000000..e6846b3 --- /dev/null +++ b/src/renderer/components/events.js @@ -0,0 +1,24 @@ +import React, { Component, PropTypes } from 'react'; +import Event from './event'; + +export default class Events extends Component { + render() { + const { + events, + onLinkClick + } = this.props; + + return ( +
+ {events.map(event => { + return ; + })} +
+ ); + } +} + +Events.propTypes = { + events: PropTypes.array.isRequired, + onLinkClick: PropTypes.func.isRequired +}; diff --git a/src/renderer/components/no-events.js b/src/renderer/components/no-events.js new file mode 100644 index 0000000..f707d65 --- /dev/null +++ b/src/renderer/components/no-events.js @@ -0,0 +1,20 @@ +import React, { Component, PropTypes } from 'react'; + +export default class NoEvents extends Component { + render() { + const { fetching } = this.props; + + const text = fetching ? 'Loading...' : 'No activities.'; + + return ( +
+ +

{text}

+
+ ); + } +} + +PropTypes.propTypes = { + fetching: PropTypes.bool.isRequired +}; diff --git a/src/renderer/components/no-notifications.js b/src/renderer/components/no-notifications.js new file mode 100644 index 0000000..b02d08b --- /dev/null +++ b/src/renderer/components/no-notifications.js @@ -0,0 +1,12 @@ +import React, { Component } from 'react'; + +export default class NoNotifications extends Component { + render() { + return ( +
+ +

No new notifications.

+
+ ); + } +} diff --git a/src/renderer/components/notification-section.js b/src/renderer/components/notification-section.js new file mode 100644 index 0000000..aaa349f --- /dev/null +++ b/src/renderer/components/notification-section.js @@ -0,0 +1,36 @@ +import React, { Component, PropTypes } from 'react'; +import Notifications from './notifications'; + +export default class NotificationSection extends Component { + render() { + const { + name, + notifications, + markNotificationsAsReadInRepository, + onNotificationClick + } = this.props; + + const repository = notifications[0].repository; + + return ( +
+
+ {name} + + + +
+ +
+ ); + } +} + +NotificationSection.propTypes = { + name: PropTypes.string.isRequired, + notifications: PropTypes.array.isRequired, + markNotificationsAsReadInRepository: PropTypes.func.isRequired, + onNotificationClick: PropTypes.func.isRequired +}; diff --git a/src/renderer/components/notification.js b/src/renderer/components/notification.js new file mode 100644 index 0000000..20c313b --- /dev/null +++ b/src/renderer/components/notification.js @@ -0,0 +1,42 @@ +import React, { Component, PropTypes } from 'react'; +import moment from 'moment'; + +export default class Notification extends Component { + render() { + const { + notification, + onClick + } = this.props; + + const octiconClassName = () => { + switch (notification.subject.type) { + case 'Commit': return 'octicon-git-commit'; + case 'Issue': return 'octicon-issue-opened'; + case 'PullRequest': return 'octicon-git-pull-request'; + case 'Release': return 'octicon-tag'; + default: return ''; + } + }(); + + const iconClassName = `octicon ${octiconClassName} pull-left notification-icon`; + + return ( +
+ +
+

+ {notification.subject.title} +

+

+ {moment(notification.updated_at).fromNow()} +

+
+
+ ); + } +} + +Notification.propTypes = { + notification: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired +}; diff --git a/src/renderer/components/notifications.js b/src/renderer/components/notifications.js new file mode 100644 index 0000000..fefdd69 --- /dev/null +++ b/src/renderer/components/notifications.js @@ -0,0 +1,35 @@ +import React, { Component, PropTypes } from 'react'; +import Notification from './notification'; + +export default class Notifications extends Component { + render() { + const { + notifications, + onNotificationClick + } = this.props; + + if (notifications.length > 0) { + const repository = notifications[0].repository; + + return ( +
+ {notifications.map(notification => { + return ; + })} +
+ ); + } else { + return ( +
+ ); + } + } +} + +Notifications.propTypes = { + notifications: PropTypes.array.isRequired, + onNotificationClick: PropTypes.func.isRequired +}; diff --git a/src/renderer/containers/events-page.js b/src/renderer/containers/events-page.js new file mode 100644 index 0000000..8ee7890 --- /dev/null +++ b/src/renderer/containers/events-page.js @@ -0,0 +1,45 @@ +import shell from 'shell'; +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import Events from '../components/events'; +import NoEvents from '../components/no-events'; + +class EventsPage extends Component { + onLinkClick(url) { + shell.openExternal(url); + } + + render() { + const { + events, + fetchingEvents + } = this.props; + + let content; + if (events.length > 0) { + content = ; + } else { + content = ; + } + + return ( +
+ {content} +
+ ); + } +} + +EventsPage.propTypes = { + events: PropTypes.array.isRequired, + fetchingEvents: PropTypes.bool.isRequired +}; + +function mapStateToProps(state) { + return { + events: state.events, + fetchingEvents: state.fetchingEvents + }; +} + +export default connect(mapStateToProps)(EventsPage); diff --git a/src/renderer/containers/footer.js b/src/renderer/containers/footer.js new file mode 100644 index 0000000..b4cd664 --- /dev/null +++ b/src/renderer/containers/footer.js @@ -0,0 +1,203 @@ +import ipc from 'ipc'; +import remote from 'remote'; +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { + setAccessToken, + setEventsUpdateInterval, + setNotificationsUpdateInterval, + setGitHubStatusUpdateInterval, + fetchEventsIfNeeded, + fetchNotificationsIfNeeded, + fetchGitHubStatus, + clearEvents, + clearNotifications, + clearGitHubStatus +} from '../actions'; + +const app = remote.require('app'); +const Menu = remote.require('menu'); +const MenuItem = remote.require('menu-item'); + +class Footer extends Component { + componentDidMount() { + const { dispatch } = this.props; + + dispatch(fetchGitHubStatus()); + } + + setUpdateInterval(type, interval) { + const { dispatch } = this.props; + + switch (type) { + case 'events': + dispatch(setEventsUpdateInterval(interval)); + break; + + case 'notifications': + dispatch(setNotificationsUpdateInterval(interval)); + break; + + case 'github-status': + dispatch(setGitHubStatusUpdateInterval(interval)); + break; + + default: + break; + } + } + + reload() { + const { dispatch } = this.props; + + dispatch(fetchEventsIfNeeded()); + dispatch(fetchNotificationsIfNeeded()); + dispatch(fetchGitHubStatus()); + } + + signOut() { + const { dispatch } = this.props; + + dispatch(setAccessToken('')); + dispatch(clearEvents()); + dispatch(clearNotifications()); + dispatch(clearGitHubStatus()); + + ipc.send('stop-auto-update-timer', 'events'); + ipc.send('stop-auto-update-timer', 'notifications'); + ipc.send('stop-auto-update-timer', 'github-status'); + } + + quit() { + app.quit(); + } + + updateIntervalMenuTemplate(type, currentInterval) { + const template = []; + + const intervals = { + 'Off': 0, + '1 minute': 1000 * 60, + '3 minutes': 1000 * 60 * 3, + '5 minutes': 1000 * 60 * 5, + '10 minutes': 1000 * 60 * 10, + '30 minutes': 1000 * 60 * 30, + '60 minutes': 1000 * 60 * 60, + }; + + Object.keys(intervals).forEach(key => { + const interval = intervals[key]; + + template.push({ + label: key, + type: 'radio', + checked: (currentInterval === interval), + click: this.setUpdateInterval.bind(this, type, interval) + }); + }); + + template.splice(1, 0, { type: 'separator' }); + + return template; + } + + showSettingsMenu() { + const { + eventsUpdateInterval, + notificationsUpdateInterval, + gitHubStatusUpdateInterval + } = this.props; + + const menu = Menu.buildFromTemplate([ + { + label: 'Reload', + click: this.reload.bind(this) + }, + { + type: 'separator' + }, + { + label: 'Events Update Interval', + type: 'submenu', + submenu: this.updateIntervalMenuTemplate('events', eventsUpdateInterval) + }, + { + label: 'Notifications Update Interval', + type: 'submenu', + submenu: this.updateIntervalMenuTemplate('notifications', notificationsUpdateInterval) + }, + { + label: 'GitHub Status Update Interval', + type: 'submenu', + submenu: this.updateIntervalMenuTemplate('github-status', gitHubStatusUpdateInterval) + }, + { + type: 'separator' + }, + { + label: 'Sign Out', + click: this.signOut.bind(this) + }, + { + type: 'separator' + }, + { + label: 'Quit', + click: this.quit.bind(this) + } + ]); + + const rect = event.target.getBoundingClientRect(); + menu.popup( + remote.getCurrentWindow(), + Math.round(rect.left + rect.width), + Math.round(rect.top + rect.height) + ); + } + + render() { + const { gitHubStatus } = this.props; + + const statusTextClass = () => { + switch (gitHubStatus.status) { + case 'good': return 'text-success'; + case 'minor': return 'text-warning'; + case 'major': return 'text-danger'; + default: return 'text-muted'; + } + }(); + + return ( +
+
+ + + + +
+

+ {'GitHub Status: '} + {gitHubStatus.status} +

+
+ ); + } +} + +Footer.propTypes = { + eventsUpdateInterval: PropTypes.number.isRequired, + notificationsUpdateInterval: PropTypes.number.isRequired, + gitHubStatusUpdateInterval: PropTypes.number.isRequired, + gitHubStatus: PropTypes.object.isRequired +}; + +function mapStateToProps(state) { + return { + eventsUpdateInterval: state.eventsUpdateInterval, + notificationsUpdateInterval: state.notificationsUpdateInterval, + gitHubStatusUpdateInterval: state.gitHubStatusUpdateInterval, + gitHubStatus: state.gitHubStatus + }; +} + +export default connect(mapStateToProps)(Footer); diff --git a/src/renderer/containers/header.js b/src/renderer/containers/header.js new file mode 100644 index 0000000..644b462 --- /dev/null +++ b/src/renderer/containers/header.js @@ -0,0 +1,59 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { + ActivePage, + setActivePage +} from '../actions'; + +const { EVENTS, NOTIFICATIONS } = ActivePage; + +class Header extends Component { + render() { + const { + dispatch, + activePage + } = this.props; + + const baseClassName = 'btn btn-primary-outline'; + let eventsClassName = baseClassName; + let notificationsClassName = baseClassName; + + if (activePage === EVENTS) { + eventsClassName += ' active'; + } else { + notificationsClassName += ' active'; + } + + return ( +
+
+ { dispatch(setActivePage(EVENTS)); }}> + Activities + + { dispatch(setActivePage(NOTIFICATIONS)); }}> + Notifications + +
+
+ ); + } +} + +Header.propTypes = { + activePage: PropTypes.oneOf([ + EVENTS, + NOTIFICATIONS + ]).isRequired +}; + +function mapStateToProps(state) { + return { + activePage: state.activePage + }; +} + +export default connect(mapStateToProps)(Header); diff --git a/src/renderer/containers/main-page.js b/src/renderer/containers/main-page.js new file mode 100644 index 0000000..a8c9006 --- /dev/null +++ b/src/renderer/containers/main-page.js @@ -0,0 +1,45 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { ActivePage } from '../actions'; +import Header from '../containers/header'; +import Footer from '../containers/footer'; +import EventsPage from './events-page'; +import NotificationsPage from './notifications-page'; + +const { EVENTS, NOTIFICATIONS } = ActivePage; + +class MainPage extends Component { + render() { + const { activePage } = this.props; + + let content; + if (activePage === EVENTS) { + content = ; + } else { + content = ; + } + + return ( +
+
+ {content} +
+
+ ); + } +} + +MainPage.propTypes = { + activePage: PropTypes.oneOf([ + EVENTS, + NOTIFICATIONS + ]).isRequired +}; + +function mapStateToProps(state) { + return { + activePage: state.activePage + }; +} + +export default connect(mapStateToProps)(MainPage); diff --git a/src/renderer/containers/notifications-page.js b/src/renderer/containers/notifications-page.js new file mode 100644 index 0000000..b50060a --- /dev/null +++ b/src/renderer/containers/notifications-page.js @@ -0,0 +1,65 @@ +import shell from 'shell'; +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { + markNotificationAsRead, + markNotificationsAsReadInRepository +} from '../actions'; +import { notificationHtmlUrl } from '../../lib/notification-utils'; +import NotificationSection from '../components/notification-section'; +import NoNotifications from '../components/no-notifications'; + +class NotificationsPage extends Component { + handleNotificationClick(notification) { + const { dispatch } = this.props; + + dispatch(markNotificationAsRead(notification)); + + // Open notification page in the default browser + const url = notificationHtmlUrl(notification); + shell.openExternal(url); + } + + render() { + const { + dispatch, + notifications + } = this.props; + + const boundMarkNotificationsAsReadInRepository = (repository) => { + dispatch(markNotificationsAsReadInRepository(repository)); + }; + + let content; + if (Object.keys(notifications).length > 0) { + content = Object.keys(notifications).map(key => { + return ; + }); + } else { + content = ; + } + + return ( +
+ {content} +
+ ); + } +} + +NotificationsPage.propTypes = { + notifications: PropTypes.object.isRequired +}; + +function mapStateToProps(state) { + return { + notifications: state.notifications + }; +} + +export default connect(mapStateToProps)(NotificationsPage); diff --git a/src/renderer/containers/root.js b/src/renderer/containers/root.js new file mode 100644 index 0000000..01076d9 --- /dev/null +++ b/src/renderer/containers/root.js @@ -0,0 +1,40 @@ +import React, { Component, PropTypes } from 'react'; +import { connect } from 'react-redux'; +import SignInPage from './sign-in-page'; +import MainPage from '../containers/main-page'; + +class Root extends Component { + render() { + const { + accessToken, + user + } = this.props; + + let content; + if (accessToken === '' || Object.keys(user).length === 0) { + content = ; + } else { + content = ; + } + + return ( +
+ {content} +
+ ); + } +} + +Root.propTypes = { + accessToken: PropTypes.string.isRequired, + user: PropTypes.object.isRequired +}; + +function mapStateToProps(state) { + return { + accessToken: state.accessToken, + user: state.user + }; +} + +export default connect(mapStateToProps)(Root); diff --git a/src/renderer/containers/sign-in-page.js b/src/renderer/containers/sign-in-page.js new file mode 100644 index 0000000..2734301 --- /dev/null +++ b/src/renderer/containers/sign-in-page.js @@ -0,0 +1,43 @@ +import ipc from 'ipc'; +import remote from 'remote'; +import React, { Component } from 'react'; + +const app = remote.require('app'); + +export default class SignInPage extends Component { + signIn() { + ipc.send('authenticate'); + } + + quit() { + app.quit(); + } + + render() { + return ( +
+

Welcome to PopHub!

+ +

+ You need to authorize PopHub to use your GitHub account.
+ Authorization page will be opened. +

+ + +
+ ); + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..942caf6 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,18 @@ + + + + + + + + + PopHub + + +
+
+
+ + + + diff --git a/src/renderer/index.js b/src/renderer/index.js new file mode 100644 index 0000000..c5a6345 --- /dev/null +++ b/src/renderer/index.js @@ -0,0 +1,131 @@ +import ipc from 'ipc'; +import remote from 'remote'; +import React from 'react'; +import { Provider } from 'react-redux'; +import configureStore from './store'; +import GitHubClient from '../lib/github-client'; +import Root from './containers/root'; +import { + ActivePage, + setAccessToken, + setUser, + fetchEventsIfNeeded, + fetchNotificationsIfNeeded, + fetchGitHubStatus +} from './actions'; +const dialog = remote.require('dialog'); +const { EVENTS, NOTIFICATIONS } = ActivePage; + +// Create store +const store = configureStore(); + +// Subscribe store to get the change of state +let currentState; +function handleChange() { + const previousState = currentState; + currentState = Object.assign({}, store.getState()); + + if (previousState) { + const previousEventsUpdateInterval = previousState.eventsUpdateInterval; + const previousNotificationsUpdateInterval = previousState.notificationsUpdateInterval; + const previousGitHubStatusUpdateInterval = previousState.gitHubStatusUpdateInterval; + + const currentEventsUpdateInterval = currentState.eventsUpdateInterval; + const currentNotificationsUpdateInterval = currentState.notificationsUpdateInterval; + const currentGitHubStatusUpdateInterval = currentState.gitHubStatusUpdateInterval; + + if (previousEventsUpdateInterval !== currentEventsUpdateInterval) { + ipc.send('stop-auto-update-timer', 'events'); + ipc.send('start-auto-update-timer', 'events', currentEventsUpdateInterval); + } + + if (previousNotificationsUpdateInterval !== currentNotificationsUpdateInterval) { + ipc.send('stop-auto-update-timer', 'notifications'); + ipc.send('start-auto-update-timer', 'notifications', currentNotificationsUpdateInterval); + } + + if (previousGitHubStatusUpdateInterval !== currentGitHubStatusUpdateInterval) { + ipc.send('stop-auto-update-timer', 'github-status'); + ipc.send('start-auto-update-timer', 'github-status', currentGitHubStatusUpdateInterval); + } + } +} + +store.subscribe(handleChange); +handleChange(); + +// Register ipc events +ipc.on('authentication-succeeded', (accessToken) => { + const { + eventsUpdateInterval, + notificationsUpdateInterval, + gitHubStatusUpdateInterval + } = store.getState(); + + store.dispatch(setAccessToken(accessToken)); + + // Get authenticated user + const client = new GitHubClient(); + client.accessToken = accessToken; + + client.get('https://api.github.com/user') + .then(user => { + store.dispatch(setUser(user)); + + store.dispatch(fetchEventsIfNeeded()); + store.dispatch(fetchNotificationsIfNeeded()); + + ipc.send('start-auto-update-timer', 'events', eventsUpdateInterval); + ipc.send('start-auto-update-timer', 'notifications', notificationsUpdateInterval); + ipc.send('start-auto-update-timer', 'github-status', gitHubStatusUpdateInterval); + + ipc.send('show-popup-window'); + }) + .catch(error => { + dialog.showErrorBox('Error', error.message); + }); +}); + +ipc.on('auto-update-timer-fired', (key) => { + switch (key) { + case 'events': + store.dispatch(fetchEventsIfNeeded()); + break; + + case 'notifications': + store.dispatch(fetchNotificationsIfNeeded()); + break; + + case 'github-status': + store.dispatch(fetchGitHubStatus()); + break; + + default: + break; + } +}); + +// Fetch data if accessToken is manually set +const { + accessToken, + eventsUpdateInterval, + notificationsUpdateInterval, + gitHubStatusUpdateInterval +} = store.getState(); + +if (accessToken) { + store.dispatch(fetchEventsIfNeeded()); + store.dispatch(fetchNotificationsIfNeeded()); + + ipc.send('start-auto-update-timer', 'events', eventsUpdateInterval); + ipc.send('start-auto-update-timer', 'notifications', eventsUpdateInterval); + ipc.send('start-auto-update-timer', 'github-status', eventsUpdateInterval); +} + +// Render view +React.render( + + {() => } + , + document.getElementById('root') +); diff --git a/src/renderer/reducers.js b/src/renderer/reducers.js new file mode 100644 index 0000000..6f4fb39 --- /dev/null +++ b/src/renderer/reducers.js @@ -0,0 +1,201 @@ +import { combineReducers } from 'redux'; +import { + SET_ACCESS_TOKEN, + SET_USER, + SET_ACTIVE_PAGE, + SET_EVENTS_UPDATE_INTERVAL, + SET_NOTIFICATIONS_UPDATE_INTERVAL, + SET_GITHUB_STATUS_UPDATE_INTERVAL, + REQUEST_EVENTS, + RECEIVE_EVENTS, + CLEAR_EVENTS, + REQUEST_NOTIFICATIONS, + RECEIVE_NOTIFICATIONS, + CLEAR_NOTIFICATIONS, + MARK_NOTIFICATION_AS_READ, + MARK_NOTIFICATIONS_AS_READ_IN_REPOSITORY, + FETCH_GITHUB_STATUS, + CLEAR_GITHUB_STATUS, + ActivePage +} from './actions'; +const { EVENTS, NOTIFICATIONS } = ActivePage; + +const MAXIMUM_NUMBER_OF_EVENTS = 200; + +function accessToken(state = '', action) { + switch (action.type) { + case SET_ACCESS_TOKEN: + return action.accessToken; + + default: + return state; + } +} + +function user(state = {}, action) { + switch (action.type) { + case SET_USER: + return action.user; + + default: + return state; + } +} + +function activePage(state = EVENTS, action) { + switch (action.type) { + case SET_ACTIVE_PAGE: + return action.activePage; + + default: + return state; + } +} + +function eventsUpdateInterval(state = 1000 * 60 * 10, action) { + switch (action.type) { + case SET_EVENTS_UPDATE_INTERVAL: + return action.eventsUpdateInterval; + + default: + return state; + } +} + +function notificationsUpdateInterval(state = 1000 * 60 * 10, action) { + switch (action.type) { + case SET_NOTIFICATIONS_UPDATE_INTERVAL: + return action.notificationsUpdateInterval; + + default: + return state; + } +} + +function gitHubStatusUpdateInterval(state = 1000 * 60 * 30, action) { + switch (action.type) { + case SET_GITHUB_STATUS_UPDATE_INTERVAL: + return action.gitHubStatusUpdateInterval; + + default: + return state; + } +} + +function fetchingEvents(state = false, action) { + switch (action.type) { + case REQUEST_EVENTS: + return true; + + case RECEIVE_EVENTS: + return false; + + default: + return state; + } +} + +function events(state = [], action) { + switch (action.type) { + case RECEIVE_EVENTS: + const events = [].concat(state); + let newEvents = action.events.concat(events); + + if (newEvents.length > MAXIMUM_NUMBER_OF_EVENTS) { + newEvents = newEvents.slice(0, MAXIMUM_NUMBER_OF_EVENTS); + } + + return newEvents; + + case CLEAR_EVENTS: + return []; + + default: + return state; + } +} + +function fetchingNotifications(state = false, action) { + switch (action.type) { + case REQUEST_NOTIFICATIONS: + return true; + + case RECEIVE_NOTIFICATIONS: + return false; + + default: + return state; + } +} + +function notifications(state = {}, action) { + switch (action.type) { + case RECEIVE_NOTIFICATIONS: + return action.notifications; + + case CLEAR_NOTIFICATIONS: + return {}; + + case MARK_NOTIFICATION_AS_READ: { + const notifications = Object.assign({}, state); + + const notification = action.notification; + const key = notification.repository.full_name; + + if (notifications[key].length === 1) { + delete notifications[key]; + } else { + const index = notifications[key].indexOf(notification); + + if (index !== -1) { + notifications[key].splice(index, 1); + } + } + + return notifications; + } + + case MARK_NOTIFICATIONS_AS_READ_IN_REPOSITORY: { + const notifications = Object.assign({}, state); + delete notifications[action.repository.full_name]; + return notifications; + } + + default: + return state; + } +} + +const defaultGitHubStatus = { + status: 'unknown', + last_updated: '' +}; + +function gitHubStatus(state = defaultGitHubStatus, action) { + switch (action.type) { + case FETCH_GITHUB_STATUS: + return action.gitHubStatus; + + case CLEAR_GITHUB_STATUS: + return defaultGitHubStatus; + + default: + return state; + } +} + +const rootReducer = combineReducers({ + accessToken, + user, + activePage, + eventsUpdateInterval, + notificationsUpdateInterval, + gitHubStatusUpdateInterval, + fetchingEvents, + events, + fetchingNotifications, + notifications, + gitHubStatus +}); + +export default rootReducer; diff --git a/src/renderer/store.js b/src/renderer/store.js new file mode 100644 index 0000000..8d4e4cc --- /dev/null +++ b/src/renderer/store.js @@ -0,0 +1,13 @@ +import rootReducer from './reducers'; +import { createStore, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; +import createLogger from 'redux-logger'; + +const logger = createLogger(); +const createStoreWithMiddleware = applyMiddleware( + thunk +)(createStore); + +export default function configureStore(initialState) { + return createStoreWithMiddleware(rootReducer, initialState); +} diff --git a/src/vendor/css/normalize.css b/src/vendor/css/normalize.css new file mode 100644 index 0000000..458eea1 --- /dev/null +++ b/src/vendor/css/normalize.css @@ -0,0 +1,427 @@ +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ + +/** + * 1. Set default font family to sans-serif. + * 2. Prevent iOS text size adjust after orientation change, without disabling + * user zoom. + */ + +html { + font-family: sans-serif; /* 1 */ + -ms-text-size-adjust: 100%; /* 2 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/** + * Remove default margin. + */ + +body { + margin: 0; +} + +/* HTML5 display definitions + ========================================================================== */ + +/** + * Correct `block` display not defined for any HTML5 element in IE 8/9. + * Correct `block` display not defined for `details` or `summary` in IE 10/11 + * and Firefox. + * Correct `block` display not defined for `main` in IE 11. + */ + +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} + +/** + * 1. Correct `inline-block` display not defined in IE 8/9. + * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. + */ + +audio, +canvas, +progress, +video { + display: inline-block; /* 1 */ + vertical-align: baseline; /* 2 */ +} + +/** + * Prevent modern browsers from displaying `audio` without controls. + * Remove excess height in iOS 5 devices. + */ + +audio:not([controls]) { + display: none; + height: 0; +} + +/** + * Address `[hidden]` styling not present in IE 8/9/10. + * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. + */ + +[hidden], +template { + display: none; +} + +/* Links + ========================================================================== */ + +/** + * Remove the gray background color from active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * Improve readability when focused and also mouse hovered in all browsers. + */ + +a:active, +a:hover { + outline: 0; +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Address styling not present in IE 8/9/10/11, Safari, and Chrome. + */ + +abbr[title] { + border-bottom: 1px dotted; +} + +/** + * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. + */ + +b, +strong { + font-weight: bold; +} + +/** + * Address styling not present in Safari and Chrome. + */ + +dfn { + font-style: italic; +} + +/** + * Address variable `h1` font-size and margin within `section` and `article` + * contexts in Firefox 4+, Safari, and Chrome. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/** + * Address styling not present in IE 8/9. + */ + +mark { + background: #ff0; + color: #000; +} + +/** + * Address inconsistent and variable font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` affecting `line-height` in all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sup { + top: -0.5em; +} + +sub { + bottom: -0.25em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove border when inside `a` element in IE 8/9/10. + */ + +img { + border: 0; +} + +/** + * Correct overflow not hidden in IE 9/10/11. + */ + +svg:not(:root) { + overflow: hidden; +} + +/* Grouping content + ========================================================================== */ + +/** + * Address margin not present in IE 8/9 and Safari. + */ + +figure { + margin: 1em 40px; +} + +/** + * Address differences between Firefox and other browsers. + */ + +hr { + -moz-box-sizing: content-box; + box-sizing: content-box; + height: 0; +} + +/** + * Contain overflow in all browsers. + */ + +pre { + overflow: auto; +} + +/** + * Address odd `em`-unit font size rendering in all browsers. + */ + +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} + +/* Forms + ========================================================================== */ + +/** + * Known limitation: by default, Chrome and Safari on OS X allow very limited + * styling of `select`, unless a `border` property is set. + */ + +/** + * 1. Correct color not being inherited. + * Known issue: affects color of disabled elements. + * 2. Correct font properties not being inherited. + * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. + */ + +button, +input, +optgroup, +select, +textarea { + color: inherit; /* 1 */ + font: inherit; /* 2 */ + margin: 0; /* 3 */ +} + +/** + * Address `overflow` set to `hidden` in IE 8/9/10/11. + */ + +button { + overflow: visible; +} + +/** + * Address inconsistent `text-transform` inheritance for `button` and `select`. + * All other form control elements do not inherit `text-transform` values. + * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. + * Correct `select` style inheritance in Firefox. + */ + +button, +select { + text-transform: none; +} + +/** + * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` + * and `video` controls. + * 2. Correct inability to style clickable `input` types in iOS. + * 3. Improve usability and consistency of cursor style between image-type + * `input` and others. + */ + +button, +html input[type="button"], /* 1 */ +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; /* 2 */ + cursor: pointer; /* 3 */ +} + +/** + * Re-set default cursor for disabled elements. + */ + +button[disabled], +html input[disabled] { + cursor: default; +} + +/** + * Remove inner padding and border in Firefox 4+. + */ + +button::-moz-focus-inner, +input::-moz-focus-inner { + border: 0; + padding: 0; +} + +/** + * Address Firefox 4+ setting `line-height` on `input` using `!important` in + * the UA stylesheet. + */ + +input { + line-height: normal; +} + +/** + * It's recommended that you don't attempt to style these elements. + * Firefox's implementation doesn't respect box-sizing, padding, or width. + * + * 1. Address box sizing set to `content-box` in IE 8/9/10. + * 2. Remove excess padding in IE 8/9/10. + */ + +input[type="checkbox"], +input[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Fix the cursor style for Chrome's increment/decrement buttons. For certain + * `font-size` values of the `input`, it causes the cursor style of the + * decrement button to change from `default` to `text`. + */ + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Address `appearance` set to `searchfield` in Safari and Chrome. + * 2. Address `box-sizing` set to `border-box` in Safari and Chrome + * (include `-moz` to future-proof). + */ + +input[type="search"] { + -webkit-appearance: textfield; /* 1 */ + -moz-box-sizing: content-box; + -webkit-box-sizing: content-box; /* 2 */ + box-sizing: content-box; +} + +/** + * Remove inner padding and search cancel button in Safari and Chrome on OS X. + * Safari (but not Chrome) clips the cancel button when the search input has + * padding (and `textfield` appearance). + */ + +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * Define consistent border, margin, and padding. + */ + +fieldset { + border: 1px solid #c0c0c0; + margin: 0 2px; + padding: 0.35em 0.625em 0.75em; +} + +/** + * 1. Correct `color` not being inherited in IE 8/9/10/11. + * 2. Remove padding so people aren't caught out if they zero out fieldsets. + */ + +legend { + border: 0; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Remove default vertical scrollbar in IE 8/9/10/11. + */ + +textarea { + overflow: auto; +} + +/** + * Don't inherit the `font-weight` (applied by a rule above). + * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. + */ + +optgroup { + font-weight: bold; +} + +/* Tables + ========================================================================== */ + +/** + * Remove most spacing between table cells. + */ + +table { + border-collapse: collapse; + border-spacing: 0; +} + +td, +th { + padding: 0; +} diff --git a/src/vendor/octicons/octicons.css b/src/vendor/octicons/octicons.css new file mode 100755 index 0000000..2c3e0df --- /dev/null +++ b/src/vendor/octicons/octicons.css @@ -0,0 +1,221 @@ +@font-face { + font-family: 'octicons'; + src: url('octicons.eot?#iefix') format('embedded-opentype'), + url('octicons.woff') format('woff'), + url('octicons.ttf') format('truetype'), + url('octicons.svg#octicons') format('svg'); + font-weight: normal; + font-style: normal; +} + +/* + +.octicon is optimized for 16px. +.mega-octicon is optimized for 32px but can be used larger. + +*/ +.octicon, .mega-octicon { + font: normal normal normal 16px/1 octicons; + display: inline-block; + text-decoration: none; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.mega-octicon { font-size: 32px; } + +.octicon-alert:before { content: '\f02d'} /*  */ +.octicon-arrow-down:before { content: '\f03f'} /*  */ +.octicon-arrow-left:before { content: '\f040'} /*  */ +.octicon-arrow-right:before { content: '\f03e'} /*  */ +.octicon-arrow-small-down:before { content: '\f0a0'} /*  */ +.octicon-arrow-small-left:before { content: '\f0a1'} /*  */ +.octicon-arrow-small-right:before { content: '\f071'} /*  */ +.octicon-arrow-small-up:before { content: '\f09f'} /*  */ +.octicon-arrow-up:before { content: '\f03d'} /*  */ +.octicon-microscope:before, +.octicon-beaker:before { content: '\f0dd'} /*  */ +.octicon-bell:before { content: '\f0de'} /*  */ +.octicon-book:before { content: '\f007'} /*  */ +.octicon-bookmark:before { content: '\f07b'} /*  */ +.octicon-briefcase:before { content: '\f0d3'} /*  */ +.octicon-broadcast:before { content: '\f048'} /*  */ +.octicon-browser:before { content: '\f0c5'} /*  */ +.octicon-bug:before { content: '\f091'} /*  */ +.octicon-calendar:before { content: '\f068'} /*  */ +.octicon-check:before { content: '\f03a'} /*  */ +.octicon-checklist:before { content: '\f076'} /*  */ +.octicon-chevron-down:before { content: '\f0a3'} /*  */ +.octicon-chevron-left:before { content: '\f0a4'} /*  */ +.octicon-chevron-right:before { content: '\f078'} /*  */ +.octicon-chevron-up:before { content: '\f0a2'} /*  */ +.octicon-circle-slash:before { content: '\f084'} /*  */ +.octicon-circuit-board:before { content: '\f0d6'} /*  */ +.octicon-clippy:before { content: '\f035'} /*  */ +.octicon-clock:before { content: '\f046'} /*  */ +.octicon-cloud-download:before { content: '\f00b'} /*  */ +.octicon-cloud-upload:before { content: '\f00c'} /*  */ +.octicon-code:before { content: '\f05f'} /*  */ +.octicon-color-mode:before { content: '\f065'} /*  */ +.octicon-comment-add:before, +.octicon-comment:before { content: '\f02b'} /*  */ +.octicon-comment-discussion:before { content: '\f04f'} /*  */ +.octicon-credit-card:before { content: '\f045'} /*  */ +.octicon-dash:before { content: '\f0ca'} /*  */ +.octicon-dashboard:before { content: '\f07d'} /*  */ +.octicon-database:before { content: '\f096'} /*  */ +.octicon-clone:before, +.octicon-desktop-download:before { content: '\f0dc'} /*  */ +.octicon-device-camera:before { content: '\f056'} /*  */ +.octicon-device-camera-video:before { content: '\f057'} /*  */ +.octicon-device-desktop:before { content: '\f27c'} /*  */ +.octicon-device-mobile:before { content: '\f038'} /*  */ +.octicon-diff:before { content: '\f04d'} /*  */ +.octicon-diff-added:before { content: '\f06b'} /*  */ +.octicon-diff-ignored:before { content: '\f099'} /*  */ +.octicon-diff-modified:before { content: '\f06d'} /*  */ +.octicon-diff-removed:before { content: '\f06c'} /*  */ +.octicon-diff-renamed:before { content: '\f06e'} /*  */ +.octicon-ellipsis:before { content: '\f09a'} /*  */ +.octicon-eye-unwatch:before, +.octicon-eye-watch:before, +.octicon-eye:before { content: '\f04e'} /*  */ +.octicon-file-binary:before { content: '\f094'} /*  */ +.octicon-file-code:before { content: '\f010'} /*  */ +.octicon-file-directory:before { content: '\f016'} /*  */ +.octicon-file-media:before { content: '\f012'} /*  */ +.octicon-file-pdf:before { content: '\f014'} /*  */ +.octicon-file-submodule:before { content: '\f017'} /*  */ +.octicon-file-symlink-directory:before { content: '\f0b1'} /*  */ +.octicon-file-symlink-file:before { content: '\f0b0'} /*  */ +.octicon-file-text:before { content: '\f011'} /*  */ +.octicon-file-zip:before { content: '\f013'} /*  */ +.octicon-flame:before { content: '\f0d2'} /*  */ +.octicon-fold:before { content: '\f0cc'} /*  */ +.octicon-gear:before { content: '\f02f'} /*  */ +.octicon-gift:before { content: '\f042'} /*  */ +.octicon-gist:before { content: '\f00e'} /*  */ +.octicon-gist-secret:before { content: '\f08c'} /*  */ +.octicon-git-branch-create:before, +.octicon-git-branch-delete:before, +.octicon-git-branch:before { content: '\f020'} /*  */ +.octicon-git-commit:before { content: '\f01f'} /*  */ +.octicon-git-compare:before { content: '\f0ac'} /*  */ +.octicon-git-merge:before { content: '\f023'} /*  */ +.octicon-git-pull-request-abandoned:before, +.octicon-git-pull-request:before { content: '\f009'} /*  */ +.octicon-globe:before { content: '\f0b6'} /*  */ +.octicon-graph:before { content: '\f043'} /*  */ +.octicon-heart:before { content: '\2665'} /* ♥ */ +.octicon-history:before { content: '\f07e'} /*  */ +.octicon-home:before { content: '\f08d'} /*  */ +.octicon-horizontal-rule:before { content: '\f070'} /*  */ +.octicon-hubot:before { content: '\f09d'} /*  */ +.octicon-inbox:before { content: '\f0cf'} /*  */ +.octicon-info:before { content: '\f059'} /*  */ +.octicon-issue-closed:before { content: '\f028'} /*  */ +.octicon-issue-opened:before { content: '\f026'} /*  */ +.octicon-issue-reopened:before { content: '\f027'} /*  */ +.octicon-jersey:before { content: '\f019'} /*  */ +.octicon-key:before { content: '\f049'} /*  */ +.octicon-keyboard:before { content: '\f00d'} /*  */ +.octicon-law:before { content: '\f0d8'} /*  */ +.octicon-light-bulb:before { content: '\f000'} /*  */ +.octicon-link:before { content: '\f05c'} /*  */ +.octicon-link-external:before { content: '\f07f'} /*  */ +.octicon-list-ordered:before { content: '\f062'} /*  */ +.octicon-list-unordered:before { content: '\f061'} /*  */ +.octicon-location:before { content: '\f060'} /*  */ +.octicon-gist-private:before, +.octicon-mirror-private:before, +.octicon-git-fork-private:before, +.octicon-lock:before { content: '\f06a'} /*  */ +.octicon-logo-github:before { content: '\f092'} /*  */ +.octicon-mail:before { content: '\f03b'} /*  */ +.octicon-mail-read:before { content: '\f03c'} /*  */ +.octicon-mail-reply:before { content: '\f051'} /*  */ +.octicon-mark-github:before { content: '\f00a'} /*  */ +.octicon-markdown:before { content: '\f0c9'} /*  */ +.octicon-megaphone:before { content: '\f077'} /*  */ +.octicon-mention:before { content: '\f0be'} /*  */ +.octicon-milestone:before { content: '\f075'} /*  */ +.octicon-mirror-public:before, +.octicon-mirror:before { content: '\f024'} /*  */ +.octicon-mortar-board:before { content: '\f0d7'} /*  */ +.octicon-mute:before { content: '\f080'} /*  */ +.octicon-no-newline:before { content: '\f09c'} /*  */ +.octicon-octoface:before { content: '\f008'} /*  */ +.octicon-organization:before { content: '\f037'} /*  */ +.octicon-package:before { content: '\f0c4'} /*  */ +.octicon-paintcan:before { content: '\f0d1'} /*  */ +.octicon-pencil:before { content: '\f058'} /*  */ +.octicon-person-add:before, +.octicon-person-follow:before, +.octicon-person:before { content: '\f018'} /*  */ +.octicon-pin:before { content: '\f041'} /*  */ +.octicon-plug:before { content: '\f0d4'} /*  */ +.octicon-repo-create:before, +.octicon-gist-new:before, +.octicon-file-directory-create:before, +.octicon-file-add:before, +.octicon-plus:before { content: '\f05d'} /*  */ +.octicon-primitive-dot:before { content: '\f052'} /*  */ +.octicon-primitive-square:before { content: '\f053'} /*  */ +.octicon-pulse:before { content: '\f085'} /*  */ +.octicon-question:before { content: '\f02c'} /*  */ +.octicon-quote:before { content: '\f063'} /*  */ +.octicon-radio-tower:before { content: '\f030'} /*  */ +.octicon-repo-delete:before, +.octicon-repo:before { content: '\f001'} /*  */ +.octicon-repo-clone:before { content: '\f04c'} /*  */ +.octicon-repo-force-push:before { content: '\f04a'} /*  */ +.octicon-gist-fork:before, +.octicon-repo-forked:before { content: '\f002'} /*  */ +.octicon-repo-pull:before { content: '\f006'} /*  */ +.octicon-repo-push:before { content: '\f005'} /*  */ +.octicon-rocket:before { content: '\f033'} /*  */ +.octicon-rss:before { content: '\f034'} /*  */ +.octicon-ruby:before { content: '\f047'} /*  */ +.octicon-screen-full:before { content: '\f066'} /*  */ +.octicon-screen-normal:before { content: '\f067'} /*  */ +.octicon-search-save:before, +.octicon-search:before { content: '\f02e'} /*  */ +.octicon-server:before { content: '\f097'} /*  */ +.octicon-settings:before { content: '\f07c'} /*  */ +.octicon-shield:before { content: '\f0e1'} /*  */ +.octicon-log-in:before, +.octicon-sign-in:before { content: '\f036'} /*  */ +.octicon-log-out:before, +.octicon-sign-out:before { content: '\f032'} /*  */ +.octicon-squirrel:before { content: '\f0b2'} /*  */ +.octicon-star-add:before, +.octicon-star-delete:before, +.octicon-star:before { content: '\f02a'} /*  */ +.octicon-stop:before { content: '\f08f'} /*  */ +.octicon-repo-sync:before, +.octicon-sync:before { content: '\f087'} /*  */ +.octicon-tag-remove:before, +.octicon-tag-add:before, +.octicon-tag:before { content: '\f015'} /*  */ +.octicon-telescope:before { content: '\f088'} /*  */ +.octicon-terminal:before { content: '\f0c8'} /*  */ +.octicon-three-bars:before { content: '\f05e'} /*  */ +.octicon-thumbsdown:before { content: '\f0db'} /*  */ +.octicon-thumbsup:before { content: '\f0da'} /*  */ +.octicon-tools:before { content: '\f031'} /*  */ +.octicon-trashcan:before { content: '\f0d0'} /*  */ +.octicon-triangle-down:before { content: '\f05b'} /*  */ +.octicon-triangle-left:before { content: '\f044'} /*  */ +.octicon-triangle-right:before { content: '\f05a'} /*  */ +.octicon-triangle-up:before { content: '\f0aa'} /*  */ +.octicon-unfold:before { content: '\f039'} /*  */ +.octicon-unmute:before { content: '\f0ba'} /*  */ +.octicon-versions:before { content: '\f064'} /*  */ +.octicon-watch:before { content: '\f0e0'} /*  */ +.octicon-remove-close:before, +.octicon-x:before { content: '\f081'} /*  */ +.octicon-zap:before { content: '\26A1'} /* ⚡ */ diff --git a/src/vendor/octicons/octicons.eot b/src/vendor/octicons/octicons.eot new file mode 100755 index 0000000..2bf20bc Binary files /dev/null and b/src/vendor/octicons/octicons.eot differ diff --git a/src/vendor/octicons/octicons.svg b/src/vendor/octicons/octicons.svg new file mode 100755 index 0000000..d932988 --- /dev/null +++ b/src/vendor/octicons/octicons.svg @@ -0,0 +1,183 @@ + + + + +(c) 2012-2015 GitHub + +When using the GitHub logos, be sure to follow the GitHub logo guidelines (https://github.com/logos) + +Font License: SIL OFL 1.1 (http://scripts.sil.org/OFL) +Applies to all font files + +Code License: MIT (http://choosealicense.com/licenses/mit/) +Applies to all other files + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/vendor/octicons/octicons.ttf b/src/vendor/octicons/octicons.ttf new file mode 100755 index 0000000..32e6720 Binary files /dev/null and b/src/vendor/octicons/octicons.ttf differ diff --git a/src/vendor/octicons/octicons.woff b/src/vendor/octicons/octicons.woff new file mode 100755 index 0000000..cbf9f62 Binary files /dev/null and b/src/vendor/octicons/octicons.woff differ