├── .bowerrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── Gruntfile.js ├── LICENSE ├── Procfile ├── README.md ├── app ├── css │ ├── app.styl │ ├── base.styl │ ├── components │ │ ├── buttons.styl │ │ ├── forms.styl │ │ ├── loading.styl │ │ ├── modal.styl │ │ ├── responsive-toggle.styl │ │ ├── segmented-control.styl │ │ └── tabnav.styl │ ├── layout.styl │ ├── sections │ │ ├── conversation.styl │ │ ├── details.styl │ │ ├── diff.styl │ │ ├── lists.styl │ │ ├── shortcuts.styl │ │ ├── sidebar.styl │ │ ├── threads.styl │ │ └── unknown-subject.styl │ └── variables.styl ├── img │ ├── apple-touch-icon@120.png │ ├── apple-touch-icon@152.png │ ├── apple-touch-icon@180.png │ ├── favicon.png │ ├── mark.png │ ├── octocat-spinner-128.gif │ └── octocat-spinner-64.gif ├── js │ ├── .gitkeep │ ├── app.coffee │ ├── behaviors │ │ ├── mousetrap.coffee │ │ └── relative-time.coffee │ ├── collections │ │ ├── comments.coffee │ │ ├── events.coffee │ │ ├── filters.coffee │ │ ├── notifications.coffee │ │ ├── repositories.coffee │ │ └── timeline.coffee │ ├── controllers │ │ ├── filters.coffee │ │ ├── notification.coffee │ │ └── notifications.coffee │ ├── lib │ │ ├── app-cache.coffee │ │ ├── backbone-selectable.coffee │ │ ├── cache.coffee │ │ ├── fastclick.coffee │ │ └── paginated_collection.coffee │ ├── models │ │ ├── authentication.coffee │ │ ├── comment.coffee │ │ ├── event.coffee │ │ ├── feedback.coffee │ │ ├── filter.coffee │ │ ├── link.coffee │ │ ├── notification.coffee │ │ ├── oauth.coffee │ │ ├── repository.coffee │ │ ├── subject.coffee │ │ ├── subject │ │ │ ├── commit.coffee │ │ │ ├── issue.coffee │ │ │ ├── pull_request.coffee │ │ │ └── release.coffee │ │ ├── subscription.coffee │ │ └── token.coffee │ ├── native.coffee │ ├── routes │ │ ├── filters.coffee │ │ ├── misc.coffee │ │ └── notifications.coffee │ └── views │ │ ├── banner.coffee │ │ ├── comment.coffee │ │ ├── create_comment.coffee │ │ ├── feedback.coffee │ │ ├── filter.coffee │ │ ├── filters.coffee │ │ ├── helpers.coffee │ │ ├── lists.coffee │ │ ├── notification.coffee │ │ ├── notification_details.coffee │ │ ├── repositories.coffee │ │ ├── repository.coffee │ │ ├── shortcuts.coffee │ │ ├── subject.coffee │ │ ├── subject │ │ ├── commit.coffee │ │ ├── issue.coffee │ │ ├── pull_request.coffee │ │ ├── release.coffee │ │ └── unknown.coffee │ │ ├── subscription.coffee │ │ ├── threads.coffee │ │ ├── timeline.coffee │ │ ├── timeline │ │ ├── commit.coffee │ │ └── event.coffee │ │ └── tips.coffee ├── pages │ └── index.us └── templates │ ├── comment.us │ ├── create_comment.us │ ├── create_comment_buttons.us │ ├── feedback.us │ ├── feedback_body.us │ ├── feedback_confirm.us │ ├── lists.us │ ├── notification.us │ ├── notification_details.us │ ├── repository.us │ ├── shortcuts.us │ ├── subject.us │ ├── subject │ ├── commit.us │ ├── issue.us │ ├── pull_request.us │ ├── release.us │ └── unknown.us │ ├── subscription.us │ ├── threads.us │ ├── timeline │ ├── closed.us │ ├── commit.us │ ├── head_ref_deleted.us │ ├── head_ref_restored.us │ ├── merged.us │ ├── referenced.us │ └── reopened.us │ └── tips.us ├── bower.json ├── config ├── application.coffee ├── defaults.json ├── files.coffee ├── plugins │ ├── bower-custom.coffee │ ├── concat-sourcemap.coffee │ ├── manifest.coffee │ ├── octicons.coffee │ ├── stylus.coffee │ └── watch.coffee ├── server.js └── spec.json ├── package.json ├── script ├── bootstrap ├── server └── spec ├── server.js ├── spec ├── collections │ ├── notifications_spec.coffee │ ├── repositories_spec.coffee │ └── timeline_spec.coffee ├── helpers │ ├── helper.js │ ├── jasmine-fixture.js │ ├── jasmine-given.js │ └── jasmine-stealth.js ├── lib │ ├── cache_spec.coffee │ └── paginated_collection_spec.coffee ├── models │ ├── comment_spec.coffee │ ├── oauth_spec.coffee │ ├── repository_spec.coffee │ ├── subject_spec.coffee │ └── token_spec.coffee └── views │ ├── subject_spec.coffee │ └── timeline │ └── event_spec.coffee ├── tasks └── .gitkeep └── vendor ├── css ├── .gitkeep └── primer.css └── img └── .gitkeep /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "/vendor/bower" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | #ignore node_modules, as the node project is not "deployed" per se: http://www.mikealrogers.com/posts/nodemodules-in-git.html 4 | /node_modules 5 | 6 | /dist 7 | /generated 8 | /vendor/bower 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | #ignore node_modules, as the node project is not "deployed" per se: http://www.mikealrogers.com/posts/nodemodules-in-git.html 4 | /node_modules 5 | 6 | /dist 7 | /generated 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 0.10 4 | script: "lineman --stack spec-ci" 5 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /*global module:false*/ 2 | module.exports = function(grunt) { 3 | require(process.env['LINEMAN_MAIN']).config.grunt.run(grunt); 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2013 Brandon Keepers 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Notifications ![Build Status](https://travis-ci.org/bkeepers/github-notifications.png) 2 | 3 | a rich interface for [GitHub Notifications](https://github.com/notifications). Check it out here: https://notifications.githubapp.com/ 4 | 5 | ## Local Development 6 | 7 | To run the app locally, make sure you have a working Node.js environment, and then from the terminal run: 8 | 9 | $ script/bootstrap 10 | $ script/server 11 | 12 | This will install all the needed dependencies, start up a server, and open 13 | [localhost:8000](http://localhost:8000) in your browser. 14 | 15 | ## Contributing 16 | 17 | This project is built using [lineman](http://www.linemanjs.com/), a simple tool for 18 | building JavaScript web applications. There is no server component. The [app](app) 19 | gets compiled into static HTML, CSS, and JavaScript that uses the GitHub API. 20 | 21 | The app is mostly CoffeeScript and uses [Backbone](http://backbonejs.org). 22 | [app/js/app.coffee](app/js/app.coffee) is the starting point. 23 | 24 | The styles use [Stylus](http://learnboost.github.io/stylus/). [app/css/app.styl](app/css/app.styl) is 25 | the starting point. 26 | 27 | If you find what looks like a bug: 28 | 29 | 1. Check out the [issues on GitHub](http://github.com/bkeepers/github-notifications/issues/) to see if anyone else has reported the same issue. 30 | 3. If you don't see anything, create an issue with information on how to reproduce it. 31 | 32 | If you want to contribute an enhancement or a fix: 33 | 34 | 1. Fork the project on GitHub. 35 | 2. Make your changes. 36 | 3. Commit the changes without making changes to any other files that aren't related to your enhancement or fix. 37 | 4. Send a pull request. 38 | -------------------------------------------------------------------------------- /app/css/app.styl: -------------------------------------------------------------------------------- 1 | @import "nib"; 2 | @import "variables"; 3 | @import "base"; 4 | @import "layout"; 5 | @import "components/buttons"; 6 | @import "components/forms"; 7 | @import "components/loading"; 8 | @import "components/responsive-toggle"; 9 | @import "components/segmented-control"; 10 | @import "components/modal"; 11 | @import "components/tabnav"; 12 | 13 | @import "sections/sidebar"; 14 | @import "sections/lists"; 15 | @import "sections/threads"; 16 | @import "sections/details"; 17 | @import "sections/shortcuts"; 18 | @import "sections/conversation"; 19 | @import "sections/diff"; 20 | -------------------------------------------------------------------------------- /app/css/base.styl: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | -webkit-tap-highlight-color: rgba(0,0,0,0); 4 | } 5 | 6 | body, input, select, textarea, button { 7 | font: 13px "Helvetica Neue", Helvetica, sans-serif; 8 | line-height: 18px; 9 | } 10 | 11 | a { 12 | color: brand-blue; 13 | text-decoration: none; 14 | 15 | &:hover:, 16 | &:visited { 17 | color: brand-blue; 18 | text-decoration: underline; 19 | } 20 | } 21 | 22 | .octicon { 23 | text-align: center; 24 | } 25 | 26 | .octicon-git-pull-request { color: pull-request-color; } 27 | .octicon-issue-opened { color: issue-opened-color; } 28 | .octicon-git-commit { color: git-commit-color; } 29 | 30 | .nowrap { 31 | white-space: nowrap; 32 | overflow: hidden; 33 | text-overflow: ellipsis; 34 | } 35 | 36 | time { 37 | font-weight: 200; 38 | color: #bbbbbf; 39 | font-size: 0.85em; 40 | } 41 | 42 | .obscure-links { 43 | a { 44 | color: inherit; 45 | text-decoration: none; 46 | border-bottom: 1px dotted rgba(#000,0.2); 47 | } 48 | } 49 | 50 | .pane { 51 | header { 52 | padding: 0 18px; 53 | line-height: header-height; 54 | border-bottom: 1px solid border-color; 55 | color: base-color; 56 | background-color: header-color; 57 | 58 | & > * { 59 | vertical-align: middle; 60 | display: table-cell; 61 | white-space: nowrap; 62 | } 63 | 64 | .mega-octicon { 65 | vertical-align: center; 66 | } 67 | 68 | nav { 69 | a { 70 | text-decoration: none; 71 | color: inherit; 72 | } 73 | } 74 | 75 | .actions { 76 | text-align: right; 77 | } 78 | 79 | .actions, .responsive-toggle { 80 | width: 1%; // make it as small as possible since it is display:table-cell 81 | } 82 | 83 | @media only screen and (max-width: 768px) { 84 | padding-left: 0; 85 | 86 | .responsive-toggle { 87 | padding: 0 18px; 88 | } 89 | } 90 | } 91 | 92 | @media screen and (max-width: 768px) { 93 | box-shadow: -1px 0 5px rgba(#000, 0.2); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/css/components/buttons.styl: -------------------------------------------------------------------------------- 1 | .menubutton { 2 | display: inline-block; 3 | white-space: nowrap; 4 | border: none; 5 | background: transparent; 6 | color: inherit; 7 | text-align: center; 8 | padding: 0 9px; 9 | text-decoration: none; 10 | outline: 0; 11 | 12 | &.octicon { 13 | padding: 0; 14 | font-size: 1.5em; 15 | width: 32px; 16 | line-height: inherit; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/css/components/forms.styl: -------------------------------------------------------------------------------- 1 | // Mostly stolen from GitHub.com 2 | input[type="text"], 3 | input[type="password"], 4 | input[type="email"], 5 | input[type="number"], 6 | input[type="tel"], 7 | input[type="url"], 8 | textarea { 9 | min-height: 34px; 10 | padding: 7px 8px; 11 | outline: none; 12 | color: #333; 13 | background-color: #fff; 14 | background-repeat: no-repeat; // Repeat and position set for form states (success, error, etc) 15 | background-position: right center; 16 | border: 1px solid #ccc; 17 | border-radius: 2px; 18 | box-shadow: inset 0 1px 2px rgba(0,0,0,.075); 19 | vertical-align: middle; 20 | 21 | &.focus, 22 | &:focus { 23 | border-color: #51a7e8; 24 | box-shadow: inset 0 1px 2px rgba(0,0,0,.075), 0 0 3px rgba(81,167,232,.5); 25 | } 26 | } 27 | 28 | // Custom styling for HTML5 validation bubbles (WebKit only) 29 | ::-webkit-input-placeholder, 30 | :-moz-placeholder { color: #aaa; } 31 | 32 | input.fullwidth, textarea { 33 | display: block; 34 | width: 100%; 35 | } 36 | 37 | textarea { 38 | min-height: 100px; 39 | max-height: 500px; 40 | padding: 10px; 41 | resize: vertical; 42 | } 43 | 44 | .form-actions { 45 | margin-top: 9px; 46 | text-align: right; 47 | } 48 | 49 | .comment-and-button { display: none; } 50 | 51 | .commenting { 52 | .comment-and-button { display: inline-block; } 53 | .no-comment-button { display: none; } 54 | } 55 | -------------------------------------------------------------------------------- /app/css/components/loading.styl: -------------------------------------------------------------------------------- 1 | .loading { 2 | &:after { 3 | position: absolute; 4 | left: 0; 5 | right: 0; 6 | top: 0; 7 | bottom: 0; 8 | content: " "; 9 | background: #fff url('../img/octocat-spinner-128.gif') no-repeat center center; 10 | background-size: 64px; 11 | } 12 | } 13 | 14 | .loader { 15 | display: none; 16 | height: 64px; 17 | background: #fff url('../img/octocat-spinner-128.gif') no-repeat center center; 18 | background-size: 32px; 19 | } 20 | 21 | .paginating .loader { 22 | display: block; 23 | } 24 | -------------------------------------------------------------------------------- /app/css/components/modal.styl: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: absolute; 3 | left: 0; 4 | right: 0; 5 | top: 0; 6 | bottom: 0; 7 | background-color: rgba(#000, 0.4); 8 | z-index: 100; 9 | } 10 | 11 | .modal { 12 | width: 450px; 13 | margin: 10% auto; 14 | background: #fff; 15 | border-radius: 3px; 16 | padding: 20px; 17 | box-shadow: 0 0 18px rgba(#000, 0.4); 18 | position: relative; 19 | 20 | .close { 21 | position: absolute; 22 | right: 0px; 23 | top: 0px; 24 | padding: 20px; 25 | font-size: 18px; 26 | color: #ccc; 27 | } 28 | 29 | .header { 30 | font-size: 18px; 31 | font-weight: normal; 32 | border-bottom: 1px solid #e5e5e5; 33 | margin: -20px -20px 20px; 34 | padding: 20px; 35 | 36 | } 37 | 38 | .note { 39 | font-size: 0.9em; 40 | color: #666; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/css/components/responsive-toggle.styl: -------------------------------------------------------------------------------- 1 | // http://valdelama.com/css-responsive-navigation 2 | 3 | @media screen and (min-width: 768px) { 4 | .responsive-toggle { display: none !important; } 5 | } 6 | 7 | @media screen and (max-width: 767px) { 8 | input.responsive-toggle { 9 | position: absolute; 10 | top: -9999px; 11 | left: -9999px; 12 | } 13 | 14 | .responsive-content { 15 | transition: transform 250ms ease; 16 | } 17 | 18 | .responsive-toggle:checked ~ .responsive-content { 19 | transform: translateX(toggle-percent); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/css/components/segmented-control.styl: -------------------------------------------------------------------------------- 1 | // Segmented control implemented purely in CSS 2 | // 3 | // 10 | .segmented-control { 11 | display: inline-block; 12 | overflow: hidden; 13 | 14 | input { 15 | visibility: hidden; 16 | position: absolute; 17 | left: -5000px; 18 | } 19 | 20 | label { 21 | border: 1px solid border-color; 22 | border-right-width: 0px; 23 | line-height: 24px; 24 | padding: 0 18px; 25 | display: inline-block; 26 | float: left; 27 | } 28 | 29 | // Selected 30 | input:checked + label { 31 | background-color: selected-color; 32 | color: darken(base-color, 25%); 33 | } 34 | 35 | // input is 1st child, so label is 2nd child 36 | label:nth-child(2) { 37 | border-top-left-radius: 16px; 38 | border-bottom-left-radius: 16px; 39 | } 40 | 41 | label:last-child { 42 | border-top-right-radius: 16px; 43 | border-bottom-right-radius: 16px; 44 | border-right-width: 1px; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/css/components/tabnav.styl: -------------------------------------------------------------------------------- 1 | .tabnav { 2 | background: #f7f7f7; 3 | padding: 4px 9px 0 9px; 4 | border-radius: 3px 3px 0 0; 5 | border-bottom: 1px solid #e3e3e3; 6 | margin-bottom: 15px; 7 | } 8 | 9 | .tabnav-tabs { 10 | display: inline-block; 11 | margin: 0; 12 | padding: 0; 13 | 14 | li { 15 | display: inline-block; 16 | margin-bottom: -1px; 17 | } 18 | } 19 | 20 | .tabnav-tab { 21 | display: inline-block; 22 | padding: 6px 12px; 23 | border: 1px solid transparent; 24 | border-bottom: 0; 25 | font-size: 14px; 26 | color: #666; 27 | text-decoration: none; 28 | border-radius: 3px 3px 0 0; 29 | 30 | .write-selected &.write-tab, .preview-selected &.preview-tab { 31 | border-color: #ddd; 32 | background-color: #fff; 33 | color: #333; 34 | } 35 | } 36 | 37 | .write-bucket { display: none; } 38 | 39 | .write-selected { 40 | .write-bucket { display: block; } 41 | .preview-bucket { display: none; } 42 | } 43 | -------------------------------------------------------------------------------- /app/css/layout.styl: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | overflow: hidden; 5 | background-color: header-color; // For iOS 7 status bar 6 | background-image: linear-gradient(to bottom, #fff 0%, #fff 100%); // For iOS 7 body while loading 7 | } 8 | 9 | #app { 10 | @media screen and (min-width: 768px) { 11 | margin: 0 18px; 12 | } 13 | } 14 | 15 | .container { 16 | overflow: hidden; 17 | position: absolute; 18 | left: 0; 19 | right: 0; 20 | top: 0; 21 | bottom: 0; 22 | } 23 | 24 | .pane { 25 | position: absolute; 26 | overflow: hidden; 27 | top: 0; 28 | bottom: 0; 29 | 30 | header { 31 | position: absolute; 32 | top: 0; 33 | left: 0; 34 | right: 0; 35 | height: header-height; 36 | z-index: 4; 37 | } 38 | 39 | .content { 40 | position: absolute; 41 | top: header-height; 42 | left: 0; 43 | right: 0; 44 | bottom: 0; 45 | overflow-y: scroll; 46 | -webkit-overflow-scrolling: touch; 47 | 48 | @media screen and (min-width: 768px) { 49 | padding: 18px; 50 | } 51 | } 52 | } 53 | 54 | @media screen and (min-width: 768px) { 55 | #lists { 56 | left: 0; 57 | width: 216px; 58 | } 59 | 60 | #threads { 61 | left: 216px; 62 | width: 324px; 63 | } 64 | 65 | .details { 66 | left: 540px; 67 | right: 0; 68 | display: none; 69 | 70 | &.selected, &.default { display: block; } 71 | } 72 | } 73 | 74 | @media screen and (max-width: 767px) { 75 | .pane { 76 | left: 0; 77 | right: 0; 78 | 79 | header { 80 | position: fixed; 81 | } 82 | } 83 | 84 | #lists { 85 | z-index: 1; 86 | } 87 | 88 | #threads { 89 | z-index: 2; 90 | } 91 | 92 | .details { 93 | z-index: 3; 94 | transition: transform 250ms ease; 95 | transform: translateX(105%); // 105 to push the shadow of the screen 96 | 97 | &.selected { 98 | transform: translateX(0); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/css/sections/conversation.styl: -------------------------------------------------------------------------------- 1 | .conversation-item { 2 | @media only screen and (min-width: 768px) { 3 | margin: 9px 9px 9px 45px; 4 | } 5 | 6 | .author { 7 | color: inherit; 8 | text-decoration: none; 9 | font-weight: bold; 10 | } 11 | 12 | &:focus { 13 | outline: none; 14 | } 15 | 16 | .avatar { 17 | vertical-align: middle; 18 | border-radius: 2px; 19 | margin-right: 5px; 20 | } 21 | } 22 | 23 | .conversation-comment { 24 | .avatar { 25 | float: left; 26 | margin-left: -64px; 27 | width: 36px; 28 | height: 36px; 29 | } 30 | 31 | a { 32 | text-decoration: none; 33 | } 34 | 35 | @media only screen and (min-width: 768px) { 36 | border-radius: 2px; 37 | border: 1px solid border-color; 38 | 39 | &.selected { 40 | border: 1px solid #51a7e8; 41 | box-shadow: inset 0 1px 2px rgba(0,0,0,.075), 0 0 3px rgba(81,167,232,.5); 42 | } 43 | } 44 | 45 | @media only screen and (max-width: 767px) { 46 | border-bottom: 1px solid #f1f1f1; 47 | } 48 | 49 | &.collapsed { 50 | .conversation-content { display: none; } 51 | } 52 | } 53 | 54 | .conversation-meta { 55 | padding: 0 18px; 56 | line-height: 36px; 57 | vertical-align: middle; 58 | position: relative; 59 | cursor: pointer; 60 | 61 | time { 62 | float: right; 63 | } 64 | } 65 | 66 | .conversation-content { 67 | padding: 9px 18px; 68 | } 69 | 70 | .conversation-banner { 71 | 72 | @media only screen and (min-width: 768px) { 73 | margin: 0 0 18px 0; 74 | } 75 | 76 | @media only screen and (max-width: 767px) { 77 | border-bottom: 1px solid #f1f1f1; 78 | padding: 18px; 79 | } 80 | 81 | .conversation-event { 82 | margin: 0; 83 | } 84 | 85 | .conversation-summary { 86 | margin: 0; 87 | display: inline; 88 | 89 | .avatar { 90 | width: 20px; 91 | height: 20px; 92 | } 93 | } 94 | } 95 | 96 | .conversation-title { 97 | margin-top: 0; 98 | margin-bottom: 10px; 99 | font-weight: normal; 100 | line-height: 1.1; 101 | word-wrap: break-word; 102 | font-size: 28px; 103 | 104 | a { 105 | color: inherit; 106 | text-decoration: none; 107 | } 108 | 109 | .issue-number { 110 | color: #aaa; 111 | font-weight: 300; 112 | letter-spacing: -1px; 113 | } 114 | } 115 | 116 | .conversation-banner-meta { 117 | line-height: 30px; 118 | } 119 | 120 | .conversation-event { 121 | color: #666; 122 | 123 | .avatar { 124 | width: 16px; 125 | height: 16px; 126 | float: none; 127 | margin-top: 0; 128 | } 129 | } 130 | 131 | .conversation-event-hidden { 132 | display: none; 133 | } 134 | 135 | .conversation-item-icon { 136 | width: 30px; 137 | height: 30px; 138 | margin-right: 7px; 139 | line-height: 30px; 140 | color: #666666; 141 | text-align: center; 142 | background-color: #f3f3f3; 143 | border-radius: 50%; 144 | 145 | .conversation-event-merged & { 146 | color: #fff; 147 | background-color: git-merge-color; 148 | } 149 | 150 | .conversation-event-closed & { 151 | color: #fff; 152 | background-color: issue-closed-color; 153 | } 154 | 155 | .conversation-event-reopened & { 156 | color: #fff; 157 | background-color: issue-opened-color; 158 | } 159 | } 160 | 161 | .state-indicator { 162 | display: inline-block; 163 | padding: 0 5px; 164 | line-height: 24px; 165 | top: -4px; 166 | color: #fff; 167 | border-radius: 2px; 168 | padding: 4px 8px; 169 | margin-right: 8px; 170 | line-height: 20px; 171 | 172 | &.open { background-color: issue-opened-color; } 173 | &.closed { background-color: issue-closed-color; } 174 | &.merged { background-color: git-merge-color; } 175 | .octicon { color: #fff; } 176 | } 177 | 178 | .error-message { 179 | font-weight: bold; 180 | padding: 4px 8px; 181 | color: #900; 182 | border: 1px solid #ECC; 183 | background: #FFEAEA; 184 | border-radius: 2px; 185 | margin-bottom: 9px; 186 | display: none; 187 | 188 | .error & { display: block; } 189 | } 190 | 191 | .git-ref { 192 | position: relative; 193 | display: inline-block; 194 | padding: 0 5px; 195 | border-radius: 3px; 196 | font: 10px/20px Monaco, "Liberation Mono", Courier, monospace; 197 | color: #336479; 198 | white-space: nowrap; 199 | vertical-align: middle; 200 | background-color: #e8f0f8; 201 | } 202 | 203 | .git-sha { 204 | color: #000; 205 | font-weight: bold; 206 | font-family: monospace-font; 207 | font-size: 12px; 208 | text-decoration: none; 209 | } 210 | 211 | .commit-message { 212 | white-space: pre-wrap; 213 | font-family: monospace-font; 214 | } 215 | -------------------------------------------------------------------------------- /app/css/sections/details.styl: -------------------------------------------------------------------------------- 1 | .details { 2 | background-color: #fff; 3 | 4 | header { 5 | a { 6 | text-decoration: none; 7 | color: inherit; 8 | } 9 | } 10 | 11 | .octicon-mute:hover { 12 | color: brand-red; 13 | } 14 | 15 | .octicon-unmute { 16 | color: brand-red; 17 | &:hover { color: brand-green; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/css/sections/diff.styl: -------------------------------------------------------------------------------- 1 | .diff { 2 | margin: 0 -18px; 3 | } 4 | 5 | .file { 6 | margin-top: 15px; 7 | 8 | .meta { 9 | clearfix(); 10 | padding: 5px 18px; 11 | text-shadow: 0 1px 0 #fff; 12 | border-bottom: 1px solid #eaeaea; 13 | border-top: 1px solid #eaeaea; 14 | background-color: #f5f5f5; 15 | 16 | .info { 17 | float: left; 18 | height: 24px; 19 | line-height: 24px; 20 | 21 | a { 22 | color: inherit; 23 | font-weight: 500; 24 | } 25 | } 26 | 27 | .actions { 28 | float: right; 29 | height: 24px; 30 | line-height: 22px; 31 | } 32 | } 33 | 34 | .data { 35 | margin: 0; 36 | padding: 18px; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/css/sections/lists.styl: -------------------------------------------------------------------------------- 1 | #lists { 2 | background-color: sidebar-color; 3 | color: base-color; 4 | font-size: 12px; 5 | 6 | @media screen and (max-width: 767px) { 7 | width: toggle-percent; 8 | } 9 | 10 | header { 11 | padding-left: 18px; 12 | } 13 | 14 | h1 { 15 | background: url(../img/mark.png) no-repeat center center; 16 | background-size: 100%; 17 | text-indent: -9999px; 18 | overflow: hidden; 19 | width: 182px; 20 | height: 18px; 21 | } 22 | 23 | .list { 24 | a { 25 | color: #666; 26 | padding: 0 9px; 27 | line-height: 26px; 28 | font-size: 100; 29 | 30 | &.selected { 31 | background: brand-blue; 32 | color: #fff; 33 | border-radius: 3px; 34 | } 35 | } 36 | } 37 | 38 | .primary { 39 | display: block; 40 | font-size: 12px; 41 | margin-bottom: 18px; 42 | text-transform: uppercase; 43 | letter-spacing: 1.5px; 44 | font-weight: 500; 45 | 46 | a { 47 | line-height: 36px; 48 | } 49 | 50 | .octicon { 51 | margin-right: 4px; 52 | width: 18px; 53 | } 54 | } 55 | 56 | .title { 57 | font-weight: 400; 58 | opacity: 0.8; 59 | text-transform: uppercase; 60 | letter-spacing: 1px; 61 | font-size: 10px; 62 | line-height: 34px; 63 | padding: 0 9px; 64 | } 65 | } 66 | 67 | .list-name { 68 | @extend .nowrap; 69 | max-width: 80%; 70 | display: block; 71 | float: left; 72 | } 73 | 74 | .list-unread-count { 75 | float: right; 76 | opacity: 0.75; 77 | } 78 | 79 | #send-feedback { 80 | display: block; 81 | text-align: center; 82 | color: rgba(#000, 0.5); 83 | font-weight: bold; 84 | text-decoration: none; 85 | border: 1px solid rgba(#000, .1); 86 | margin: 20px 0; 87 | padding: 10px; 88 | border-radius: 2px; 89 | opacity: 0.5; 90 | 91 | &:hover { 92 | opacity: 1; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/css/sections/shortcuts.styl: -------------------------------------------------------------------------------- 1 | #shortcuts .modal { 2 | background: rgba(#000,0.8); 3 | color: #fff; 4 | 5 | h2 { 6 | margin: 0; 7 | margin-left: 80px; 8 | } 9 | 10 | dl { 11 | line-height: 2; 12 | } 13 | 14 | dt { 15 | float: left; 16 | width: 80px; 17 | text-align: right; 18 | padding-right: 18px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/css/sections/sidebar.styl: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | ul { 3 | list-style: none; 4 | margin: 0; 5 | padding: 0; 6 | 7 | a { 8 | display: block; 9 | text-decoration: none; 10 | color: inherit; 11 | @extend .nowrap; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/css/sections/threads.styl: -------------------------------------------------------------------------------- 1 | #threads { 2 | background: #fff; 3 | 4 | .notification { 5 | position: relative; 6 | border: 1px solid transparent; 7 | border-top-color: border-color; 8 | 9 | &:first-child { 10 | border-top-color: transparent; 11 | } 12 | 13 | &.selected { 14 | background-color: selected-color; 15 | border-color: border-color; 16 | border-radius: 3px; 17 | } 18 | 19 | &.selected + .notification { 20 | border-top-color: transparent; 21 | } 22 | 23 | a { 24 | padding: 12px 12px 12px 34px; 25 | } 26 | 27 | .octicon { 28 | position: absolute; 29 | left: 12px; 30 | top: 50%; 31 | margin-top: -8px; 32 | } 33 | } 34 | 35 | .repository { 36 | color: #818189; 37 | font-size: 0.9em; 38 | float: left; 39 | clear: both; 40 | } 41 | 42 | time { 43 | float: right; 44 | } 45 | 46 | .title { 47 | display: block; 48 | font-size: 14px; 49 | line-height: 16px; 50 | @extend .nowrap; 51 | } 52 | 53 | .unread { 54 | .title { 55 | font-weight: bold; 56 | } 57 | } 58 | 59 | .read { 60 | color: #818189; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /app/css/sections/unknown-subject.styl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/css/sections/unknown-subject.styl -------------------------------------------------------------------------------- /app/css/variables.styl: -------------------------------------------------------------------------------- 1 | // Height of iOS status bar in standalone mode 2 | status-bar-height = 20px; 3 | header-height = 60px; 4 | 5 | // Width of screen to uncover for mobile nav 6 | toggle-percent = 80%; 7 | 8 | // Colors 9 | brand-blue = #4183c4; 10 | brand-green = #6CC644; 11 | brand-red = #BD2C00; 12 | brand-yellow = #E4D591; // used on private lock on .com 13 | 14 | pull-request-color = #9e157c; 15 | issue-opened-color = #5AC12C; 16 | issue-closed-color = #bd2c00; 17 | git-commit-color = #156f9e; 18 | git-merge-color = #6e5494; 19 | 20 | header-color = #fff; 21 | sidebar-color = #fff; 22 | base-color = #8F9599; 23 | border-color = #EBF0F0; 24 | selected-color = #F4F6F6; 25 | 26 | monospace-font = Consolas, "Liberation Mono", Courier, monospace; 27 | -------------------------------------------------------------------------------- /app/img/apple-touch-icon@120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/apple-touch-icon@120.png -------------------------------------------------------------------------------- /app/img/apple-touch-icon@152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/apple-touch-icon@152.png -------------------------------------------------------------------------------- /app/img/apple-touch-icon@180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/apple-touch-icon@180.png -------------------------------------------------------------------------------- /app/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/favicon.png -------------------------------------------------------------------------------- /app/img/mark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/mark.png -------------------------------------------------------------------------------- /app/img/octocat-spinner-128.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/octocat-spinner-128.gif -------------------------------------------------------------------------------- /app/img/octocat-spinner-64.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/img/octocat-spinner-64.gif -------------------------------------------------------------------------------- /app/js/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/app/js/.gitkeep -------------------------------------------------------------------------------- /app/js/app.coffee: -------------------------------------------------------------------------------- 1 | class @App 2 | @Models: {} 3 | @Collections: {} 4 | @Controllers: {} 5 | @Views: {} 6 | @Routers: {} 7 | 8 | ajax: $.ajax 9 | 10 | # FIXME: move to config 11 | endpoints: 12 | api: 'https://api.github.com/' 13 | web: 'https://github.com/' 14 | 15 | constructor: -> 16 | _.extend @, Backbone.Events 17 | 18 | $.ajaxSetup 19 | headers: 20 | 'Accept': 'application/vnd.github.v3.html+json' 21 | 22 | # An event aggrigator 23 | vent: _.extend {}, Backbone.Events 24 | 25 | # DOM is ready, initialize the App 26 | ready: => 27 | $(document.body).addClass('standalone') if window.navigator.standalone 28 | @authenticate() unless window.jasmine? 29 | 30 | # Kick off authentication 31 | authenticate: -> 32 | new App.Models.Authentication @start 33 | 34 | # User is authenticated, start the main app. 35 | start: => 36 | $('#app').show() 37 | 38 | @filters = new App.Collections.Filters([ 39 | {id: 'everything', name: 'Everything', data: {}, octicon: 'inbox'}, 40 | {id: 'participating', name: 'Participating', data: {participating: true}, reasons: ['mention', 'author', 'comment', 'state_change', 'assign'], octicon: 'comment-discussion'} 41 | {id: 'mentioned', name: 'Mentioned', data: {participating: true}, reasons: ['team_mention'], octicon: 'jersey'}, 42 | ]) 43 | 44 | @repositories = new App.Collections.Repositories() 45 | 46 | new App.Routers.Filters 47 | filters: @filters 48 | vent: @vent 49 | 50 | new App.Controllers.Filters 51 | filters: @filters 52 | repositories: @repositories 53 | vent: @vent 54 | 55 | new App.Routers.Notifications(@vent) 56 | new App.Controllers.Notification(@vent) 57 | 58 | new App.Views.Shortcuts(vent: @vent, repositories: @repositories) 59 | 60 | new App.Routers.Misc 61 | 62 | Backbone.history.start() unless Backbone.History.started 63 | 64 | Backbone.history.navigate 'everything', trigger: true 65 | 66 | # Notifictions do not get marked as read when in development mode. 67 | isDevelopment: -> 68 | localStorage['dev']? 69 | 70 | toggleDevelopment: -> 71 | if localStorage['dev'] 72 | localStorage.removeItem('dev') 73 | console.log 'Development mode disabled' 74 | else 75 | localStorage['dev'] = true 76 | console.log 'Development mode enabled' 77 | 78 | window.app = new App() 79 | 80 | # Initialize the app 81 | $ app.ready 82 | -------------------------------------------------------------------------------- /app/js/behaviors/mousetrap.coffee: -------------------------------------------------------------------------------- 1 | # Override to propogate through inputs if meta key is pressed 2 | original = Mousetrap.stopCallback 3 | Mousetrap.stopCallback = (event, element, shortcut) -> 4 | !event.metaKey && original.apply(this, arguments) 5 | -------------------------------------------------------------------------------- /app/js/behaviors/relative-time.coffee: -------------------------------------------------------------------------------- 1 | moment.lang 'en', 2 | relativeTime : 3 | future: '%s' 4 | past: '%s' 5 | s: 'now' 6 | m: '1m' 7 | mm: '%dm' 8 | h: '1h' 9 | hh: '%dh' 10 | d: '1d' 11 | dd: '%dd' 12 | M: '1M' 13 | MM: '%dM' 14 | y: '1y' 15 | yy: '%dy' 16 | 17 | refreshRelativeTimes = (container = document) -> 18 | for el in $(container).find '.js-relative-time[datetime]' 19 | if date = moment $(el).attr('datetime'), 'YYYY-MM-DDTHH:mm:ssZ' 20 | el.textContent = date.fromNow() 21 | el.title = date.format("dddd, MMMM Do YYYY, h:mm:ss a") 22 | return 23 | 24 | app.on 'render', (view) -> 25 | refreshRelativeTimes(view.el) 26 | 27 | # Refresh relative dates every min 28 | setInterval refreshRelativeTimes, 60000 29 | -------------------------------------------------------------------------------- /app/js/collections/comments.coffee: -------------------------------------------------------------------------------- 1 | class App.Collections.Comments extends PaginatedCollection 2 | model: App.Models.Comment 3 | 4 | initialize: (models, options = {}) -> 5 | @subject = options.subject 6 | -------------------------------------------------------------------------------- /app/js/collections/events.coffee: -------------------------------------------------------------------------------- 1 | class App.Collections.Events extends PaginatedCollection 2 | model: App.Models.Event 3 | 4 | initialize: (models = [], options = {}) -> 5 | @subject = options.subject 6 | -------------------------------------------------------------------------------- /app/js/collections/filters.coffee: -------------------------------------------------------------------------------- 1 | class App.Collections.Filters extends Backbone.Collection 2 | model: App.Models.Filter 3 | -------------------------------------------------------------------------------- /app/js/collections/notifications.coffee: -------------------------------------------------------------------------------- 1 | class App.Collections.Notifications extends Backbone.Collection 2 | model: App.Models.Notification 3 | pollInterval: 60 * 1000 4 | 5 | url: -> 6 | app.endpoints.api + 'notifications' 7 | 8 | # options.filter - a function that takes a model as an argument and returns 9 | # true if the model should be added to the collection. 10 | # options.data - default params to use on the fetch request. 11 | initialize: (models, options = {}) -> 12 | @filter = options.filter if options.filter 13 | @data = options.data || {} 14 | @on 'reset', -> @select(undefined) 15 | @on 'sync', @savePollInterval 16 | 17 | # Default filter accepts all models 18 | filter: (model) -> true 19 | 20 | fetch: (options = {}) -> 21 | @oldestTimestamp = @donePaginating = null if options.reset 22 | options.data = _.extend({}, @data, options.data || {}) 23 | # Only report success if response is modified 24 | options.ifModified = true 25 | 26 | # API uses If-Modified-Since to determine which notifications to fetch. We 27 | # want all of them if the collection is currently empty. 28 | unless @oldestTimestamp 29 | options.beforeSend = (xhr) => 30 | xhr.setRequestHeader('If-Modified-Since', '') 31 | 32 | super 33 | 34 | # Mark each notification as read 35 | read: -> 36 | @invoke 'read' 37 | 38 | # Fetch the previous page of notifications 39 | # 40 | # Returns false if last request returned zero notifications 41 | paginate: -> 42 | return $.Deferred.reject() if @donePaginating 43 | data = before: @oldestTimestamp?.toISOString() 44 | @fetch(reset: false, remove: false, data: data).done(@checkIfPaginated) 45 | 46 | # Check for new notifications 47 | poll: => 48 | clearTimeout @pollTimer 49 | @pollTimer = setTimeout @poll, @pollInterval 50 | 51 | @fetch(remove: false, reset: false) 52 | 53 | stopPolling: -> 54 | clearTimeout @pollTimer if @pollTimer 55 | 56 | checkIfPaginated: (data, options, xhr) => 57 | @donePaginating = data.length == 0 58 | 59 | # Internal: Keep notifications in reverse-chronological order 60 | comparator: (model) -> 61 | -moment(model.get('updated_at')).valueOf() 62 | 63 | # Internal: Overriden to allow filtering out some models. 64 | _prepareModel: (attrs, options) -> 65 | model = super 66 | 67 | # Save timestamp of earliest notification we've seen, regardless of filter. 68 | updated_at = moment(model.get('updated_at')) 69 | if updated_at.isBefore(@oldestTimestamp || moment()) 70 | @oldestTimestamp = updated_at 71 | 72 | model if @filter(model) 73 | 74 | # Internal: Save the poll interval returned from the API 75 | savePollInterval: (collection, _, options) -> 76 | if interval = options.xhr.getResponseHeader('X-Poll-Interval') 77 | @pollInterval = Number(interval) * 1000 78 | -------------------------------------------------------------------------------- /app/js/collections/repositories.coffee: -------------------------------------------------------------------------------- 1 | class App.Collections.Repositories extends Backbone.Collection 2 | model: App.Models.Repository 3 | url: -> 4 | app.endpoints.api + 'notifications/repositories' 5 | 6 | parse: (response, options) -> 7 | for item in response 8 | item.repository.unread_count = item.unread_count 9 | item.repository 10 | 11 | poll: => 12 | @fetch remove: false 13 | 14 | startPolling: (interval = 60 * 1000) -> 15 | setInterval @poll, interval 16 | 17 | findByName: (name) -> 18 | @find (model) -> model.get('full_name') == name 19 | -------------------------------------------------------------------------------- /app/js/collections/timeline.coffee: -------------------------------------------------------------------------------- 1 | class App.Collections.Timeline extends Backbone.Collection 2 | 3 | initialize: (models = [], options = {}) -> 4 | @subject = options.subject 5 | @collections = [] 6 | 7 | comparator: (model) -> 8 | moment model.createdAt?() || model.get('created_at') 9 | 10 | observe: (collection) -> 11 | @collections.push collection 12 | collection.on 'add', (model) => @add(model, silent: true) 13 | collection.on 'remove', (model) => @remove(model, silent: true) 14 | 15 | # Propagate all events 16 | @listenTo collection, 'all', @propagateEvents 17 | 18 | @on 'add', (model) => @listenTo model, 'selected', @select 19 | @on 'remove', (model) => @stopListening model 20 | 21 | propagateEvents: (event, model, args...) -> 22 | if model && !model.hideInTimeline?() 23 | @trigger(event, model, args...) 24 | 25 | select: (model, options) => 26 | super unless @selected == model 27 | 28 | fetch: (options) -> 29 | _.each @collections, (collection) -> 30 | collection.fetch() if collection.url 31 | 32 | # Don't add models to the timeline that don't want to be in it. 33 | _prepareModel: -> 34 | model = super 35 | return false if model.hideInTimeline?() 36 | model 37 | -------------------------------------------------------------------------------- /app/js/controllers/filters.coffee: -------------------------------------------------------------------------------- 1 | # Manages selection of Filters and Repositories 2 | # 3 | # Triggers: 4 | # - filter:selected - with a Filter 5 | # - filter:unselected - with a Filter 6 | # - repository:selected - with a Repository 7 | # - repository:unselected - with a Repository 8 | # 9 | # Listens for: 10 | # - filter:select - Selects a filter with the given id 11 | # - repository:select - Selects a Repository with the given full name 12 | class App.Controllers.Filters 13 | # Cache for recently loaded views 14 | cache: new Cache(5) 15 | 16 | constructor: (options) -> 17 | _.extend @, Backbone.Events 18 | 19 | @vent = options.vent 20 | @filters = options.filters 21 | @repositories = options.repositories 22 | 23 | @listenTo @filters, 'selected', (model) => 24 | @vent.trigger 'filter:selected', model 25 | @listenTo @repositories, 'selected', (model) => 26 | @vent.trigger 'repository:selected', model 27 | @listenTo @filters, 'unselected', (model) => 28 | @vent.trigger 'filter:unselected', model 29 | @listenTo @repositories, 'unselected', (model) => 30 | @vent.trigger 'repository:unselected', model 31 | 32 | @listenTo @vent, 'filter:select', @selectFilter 33 | @listenTo @vent, 'repository:select', @selectRepository 34 | 35 | @listenTo @vent, 'filter:selected repository:selected', @show 36 | @listenTo @vent, 'filter:unselected repository:unselected', @hide 37 | 38 | @repositories.fetch() 39 | @repositories.startPolling() 40 | 41 | new App.Views.Lists(repositories: @repositories, filters: @filters) 42 | 43 | selectRepository: (name) -> 44 | @filters.selected?.unselect() 45 | @repositories.findByName(name)?.select() 46 | 47 | selectFilter: (id) -> 48 | @repositories.selected?.unselect() 49 | @filters.get(id)?.select() 50 | 51 | show: (filter) => 52 | return unless filter 53 | 54 | # Collapse the menu on mobile 55 | $('#toggle-lists').attr('checked', false) 56 | 57 | controller = @cache.fetch filter.cid, => 58 | new App.Controllers.Notifications(@vent, filter.notifications) 59 | 60 | controller.show() 61 | 62 | hide: (model) -> 63 | @cache.get(model.cid)?.hide() 64 | -------------------------------------------------------------------------------- /app/js/controllers/notification.coffee: -------------------------------------------------------------------------------- 1 | class App.Controllers.Notification 2 | # Cache for recently loaded notification views 3 | cache: new Cache(10) 4 | 5 | constructor: (@vent) -> 6 | _.extend @, Backbone.Events 7 | @listenTo @vent, 'notification:selected', @show 8 | @listenTo @vent, 'notification:unselected', @hide 9 | 10 | show: (notification) => 11 | return unless notification 12 | 13 | view = @cache.fetch notification.cid, -> 14 | v = new App.Views.NotificationDetailsView(model: notification) 15 | # FIXME: replace with app layout 16 | $('#app').append(v.el) 17 | return v 18 | 19 | view.show() 20 | 21 | hide: (model) -> 22 | @cache.get(model.cid)?.hide() 23 | -------------------------------------------------------------------------------- /app/js/controllers/notifications.coffee: -------------------------------------------------------------------------------- 1 | class App.Controllers.Notifications 2 | constructor: (@vent, @notifications) -> 3 | _.extend @, Backbone.Events 4 | 5 | @view = new App.Views.Threads(collection: @notifications) 6 | @view.render() 7 | 8 | next: -> 9 | model = @notifications.next() || @notifications.first() 10 | model?.select() 11 | 12 | prev: -> 13 | model = @notifications.prev() || @notifications.last() 14 | model?.select() 15 | 16 | select: (id) -> 17 | @notifications?.get(id)?.select() 18 | 19 | show: -> 20 | @listenTo @notifications, 'selected', (model) => 21 | @vent.trigger 'notification:selected', model 22 | @listenTo @notifications, 'unselected', (unselected, selected) => 23 | @vent.trigger 'notification:unselected', unselected, selected 24 | 25 | @listenTo @vent, 'notification:select', @select 26 | @listenTo @vent, 'notification:next', @next 27 | @listenTo @vent, 'notification:prev', @prev 28 | 29 | $('#threads').html(@view.el) # FIXME: replace with app layout 30 | @view.show() 31 | 32 | hide: -> 33 | @stopListening() 34 | @view.hide() 35 | -------------------------------------------------------------------------------- /app/js/lib/app-cache.coffee: -------------------------------------------------------------------------------- 1 | # Update app cache every 60 seconds and when leaving the page 2 | 3 | update = -> 4 | applicationCache.update() unless applicationCache.status == applicationCache.UNCACHED 5 | 6 | setInterval update, 60 * 1000 7 | $(window).on 'beforeunload', update 8 | -------------------------------------------------------------------------------- /app/js/lib/backbone-selectable.coffee: -------------------------------------------------------------------------------- 1 | _.extend Backbone.Collection.prototype, 2 | # Select the given model. 3 | # 4 | # Triggers the 'unselected' event on the previously selected model, the 5 | # 'selected' event on the given model, and the 'selected' event on the 6 | # collection. 7 | # 8 | # collection = new Backbone.Collection 9 | # model = collection.create({name: 'Selectable Model'}) 10 | # 11 | # model.on 'selected', (selectedModel, previousModel, options) -> 12 | # console.log 'selected', @ if options.debug 13 | # 14 | # model.on 'unselected', (previousModel, selectedModel, options) -> 15 | # console.log 'unselected', @ if options.debug 16 | # 17 | # collection.select model, debug: true 18 | # 19 | # model - a model in this collection, or null to unselect 20 | # args... - arguments passed to the events. 21 | select: (model, args...) -> 22 | previous = @selected 23 | @selected = model 24 | previous.trigger 'unselected', previous, model, args... if previous 25 | model.trigger 'selected', model, previous, args... if model 26 | 27 | unselect: (args...) -> 28 | @select null, args... 29 | 30 | next: -> 31 | index = @indexOf(@selected) 32 | @at index + 1 if index >= 0 33 | 34 | prev: -> 35 | index = @indexOf(@selected) 36 | @at index - 1 37 | 38 | 39 | _.extend Backbone.Model.prototype, 40 | select: (args...) -> 41 | @collection?.select(@, args...) 42 | 43 | unselect: (args...) -> 44 | @collection?.unselect(args...) 45 | 46 | isSelected: -> 47 | @collection?.selected == @ 48 | -------------------------------------------------------------------------------- /app/js/lib/cache.coffee: -------------------------------------------------------------------------------- 1 | # A simple LRU cache 2 | class @Cache 3 | constructor: (@size = 20) -> 4 | @keys = [] 5 | @objects = {} 6 | 7 | # Fetch a value or set it if it does not exist. 8 | # 9 | # key - The cache key 10 | # constructor - a function to call if the key is not set 11 | fetch: (key, constructor) -> 12 | @get(key) || @set(key, constructor()) 13 | 14 | # Get the cached value 15 | get: (key) -> 16 | @touch(key) 17 | @objects[key] 18 | 19 | # Set the cached value 20 | set: (key, object) -> 21 | @objects[key] = object 22 | @touch(key) 23 | @clean() 24 | object 25 | 26 | # Unset the key 27 | unset: (key) -> 28 | @objects[key]?.remove?() 29 | delete @objects[key] 30 | @keys = _.without(@keys, key) 31 | 32 | # Touch the given key 33 | touch: (key) -> 34 | @keys = _.without(@keys, key) 35 | @keys.push(key) 36 | 37 | # Remove all but n of the least recently used objects 38 | clean: (n = @size) -> 39 | @unset(key) for key in @keys.slice(0, -n) 40 | -------------------------------------------------------------------------------- /app/js/lib/fastclick.coffee: -------------------------------------------------------------------------------- 1 | # Initialize Fastclick 2 | # https://github.com/ftlabs/fastclick 3 | 4 | $ -> FastClick.attach(document.body) 5 | -------------------------------------------------------------------------------- /app/js/lib/paginated_collection.coffee: -------------------------------------------------------------------------------- 1 | class @PaginatedCollection extends Backbone.Collection 2 | # The `Link` header includes pagination, like: 3 | # ; rel="first", ; rel="prev" 4 | class Link 5 | regex: /<([^>]*)>; ?rel="([^"]*)"/ 6 | 7 | @fromHeader: (header) -> 8 | _.map header.split(/\s*,\s*/), (link) -> new Link(link) 9 | 10 | constructor: (text) -> 11 | match = text.match(@regex) 12 | @href = match[1] 13 | @rel = match[2] 14 | 15 | # Override fetch method to check for pagination urls 16 | fetch: -> 17 | super.done(@paginate) 18 | 19 | # Ajax callback to fetch next page of comments if the link exists. 20 | paginate: (data, options, xhr) => 21 | if link = @nextLink(xhr.getResponseHeader("Link")) 22 | @fetch(url: link.href, reset: false, remove: false) 23 | else 24 | @trigger 'paginated' 25 | 26 | # Parse the Link header, looking for rel=next 27 | nextLink: (header) => 28 | return unless header 29 | _.find Link.fromHeader(header), (link) -> link.rel == 'next' 30 | -------------------------------------------------------------------------------- /app/js/models/authentication.coffee: -------------------------------------------------------------------------------- 1 | class App.Models.Authentication 2 | # callback - function to call once authentication is done 3 | constructor: (callback) -> 4 | $(document).ajaxError @error 5 | 6 | if App.Models.Token.get()? 7 | @done(callback) 8 | else 9 | @oauth() 10 | 11 | oauth: -> 12 | new App.Models.OAuth().authorize() 13 | 14 | # unset the token if the API responds with a 401, and try to re-authenticate. 15 | error: (event, xhr) => 16 | if xhr.status is 401 17 | App.Models.Token.set(null) 18 | @oauth() 19 | 20 | done: (callback) -> 21 | jQuery.ajaxPrefilter (options, originalOptions, xhr) => 22 | xhr.setRequestHeader 'Authorization', "token #{App.Models.Token.get()}" 23 | callback() 24 | -------------------------------------------------------------------------------- /app/js/models/comment.coffee: -------------------------------------------------------------------------------- 1 | # See: 2 | # * http://developer.github.com/v3/issues/comments/ 3 | # * http://developer.github.com/v3/repos/comments/#list-comments-for-a-single-commit 4 | class App.Models.Comment extends Backbone.Model 5 | 6 | isUnread: -> 7 | !@collection || @collection.subject.isUnreadSince(@get('created_at')) 8 | -------------------------------------------------------------------------------- /app/js/models/event.coffee: -------------------------------------------------------------------------------- 1 | class App.Models.Event extends Backbone.Model 2 | showEvents: [ 3 | 'closed', 4 | 'reopened', 5 | 'merged', 6 | 'referenced', 7 | 'head_ref_deleted', 8 | 'head_ref_restored' 9 | ] 10 | 11 | initialize: -> 12 | @url = @get('url') 13 | 14 | isUnread: -> 15 | @collection?.subject?.isUnreadSince(@get('created_at')) 16 | 17 | hideInTimeline: -> 18 | !_.contains @showEvents, @get('event') 19 | -------------------------------------------------------------------------------- /app/js/models/feedback.coffee: -------------------------------------------------------------------------------- 1 | class App.Models.Feedback extends Backbone.Model 2 | url: 'https://api.github.com/repos/bkeepers/github-notifications/issues' 3 | template: JST['app/templates/feedback_body.us'] 4 | 5 | defaults: 6 | labels: ['feedback'] 7 | 8 | validate: (attrs = @attributes, options = {}) -> 9 | attrs.body = @template(attrs) 10 | @set attrs 11 | null 12 | -------------------------------------------------------------------------------- /app/js/models/filter.coffee: -------------------------------------------------------------------------------- 1 | class App.Models.Filter extends Backbone.Model 2 | 3 | initialize: -> 4 | @notifications = new App.Collections.Notifications([], filter: @reasonFilter, data: @get('data')) 5 | 6 | reasonFilter: (model) => 7 | !@get('reasons') || model.get('reason') in @get('reasons') 8 | 9 | read: -> 10 | # noop 11 | -------------------------------------------------------------------------------- /app/js/models/link.coffee: -------------------------------------------------------------------------------- 1 | # The `Link` header includes pagination, like: 2 | # ; rel="first", ; rel="prev" 3 | class App.Models.Link 4 | regex: /<([^>]*)>; ?rel="([^"]*)"/ 5 | 6 | constructor: (text) -> 7 | match = text.match(@regex) 8 | @href = match[1] 9 | @rel = match[2] 10 | -------------------------------------------------------------------------------- /app/js/models/notification.coffee: -------------------------------------------------------------------------------- 1 | # See http://developer.github.com/v3/activity/notifications/ 2 | class App.Models.Notification extends Backbone.Model 3 | initialize: -> 4 | @url = @get('url') 5 | @subject = new App.Models.Subject.for(@) 6 | @subscription = new App.Models.Subscription(id: @id, url: @url + '/subscription') 7 | @on 'selected', @read 8 | 9 | toJSON: -> 10 | _.extend super, subject: @subject.toJSON() 11 | 12 | # Mark the notification as read. 13 | read: -> 14 | # Don't mark unsupported notifications as read 15 | return unless @subject.get('url') 16 | return if app.isDevelopment() 17 | 18 | if @get('unread') 19 | # FIXME: don't reference global `app.repositories` 20 | repository = app.repositories.get(@get("repository").id) 21 | repository?.decrement() 22 | 23 | @save {unread: false}, {patch: true} 24 | -------------------------------------------------------------------------------- /app/js/models/oauth.coffee: -------------------------------------------------------------------------------- 1 | # Client-side handling of OAuth 2 | # 3 | # This requires a local server that responds to two routes: 4 | # 5 | # GET /authenticate 6 | # returns a JSON object with OAuth client_id and scope. 7 | # 8 | # POST /authenticate/:code 9 | # Uses the code to complete OAuth and return a JSON object with a token 10 | class App.Models.OAuth 11 | location: window.location 12 | 13 | url: -> 14 | app.endpoints.web + "login/oauth/authorize" 15 | 16 | # Finish OAuth if there is a code in the parameters, otherwise initate OAuth 17 | authorize: -> 18 | if code = @getCode() 19 | @getToken(code).done(@done) 20 | else 21 | @initiate() 22 | 23 | # Get OAuth configration (client_id and scope) from the server and then 24 | # redirect to GitHub to initiate the OAuth dance. 25 | initiate: -> 26 | app.ajax url: "/authenticate", success: @redirect 27 | 28 | # Perform the redirect to GitHub 29 | # 30 | # options: 31 | # client_id - The client ID from https://github.com/settings/applications 32 | # scope - The OAuth scope needed by the application. 33 | # See: http://developer.github.com/v3/oauth/#scopes 34 | redirect: (options) => 35 | @location.assign "#{@url()}?client_id=#{options.client_id}&scope=#{options.scope}" 36 | 37 | getCode: -> 38 | match = @location.search.match(/code=(.*)/) 39 | match[1] if match 40 | 41 | getToken: (code) -> 42 | app.ajax 43 | dataType: "json" 44 | type: 'POST' 45 | url: "/authenticate/#{code}" 46 | success: (data) -> App.Models.Token.set(data.token) 47 | 48 | done: => 49 | # Redirect to get code out of URL 50 | @location.assign @location.pathname 51 | -------------------------------------------------------------------------------- /app/js/models/repository.coffee: -------------------------------------------------------------------------------- 1 | class App.Models.Repository extends Backbone.Model 2 | 3 | initialize: -> 4 | @notifications = new App.Collections.Notifications([], url: @notifications_url()) 5 | 6 | notifications_url: -> 7 | url.replace(/\{.*\}/, '') if url = @get('notifications_url') 8 | 9 | unread_count: -> 10 | count = @get('unread_count') 11 | count = '∞' if count > 999 12 | count 13 | 14 | toJSON: -> 15 | _.extend super, unread_count: @unread_count() 16 | 17 | decrement: -> 18 | return if @get('unread_count') == 0 19 | @set('unread_count', @get('unread_count') - 1) 20 | 21 | filterOptions: -> 22 | url: @notifications_url() 23 | 24 | read: -> 25 | @set('unread_count', 0) 26 | -------------------------------------------------------------------------------- /app/js/models/subject.coffee: -------------------------------------------------------------------------------- 1 | # Base class for the subject of a notification. 2 | class App.Models.Subject extends Backbone.Model 3 | @for: (notification) -> 4 | subject = notification.get('subject') 5 | subject.last_read_at = notification.get('last_read_at') 6 | model = App.Models.Subject[subject.type] || App.Models.Subject 7 | new model(subject, notification: notification) 8 | 9 | # Override in subclass to show an icon 10 | octicon: null 11 | 12 | initialize: (attributes, options = {}) -> 13 | @url = @get('url') 14 | @notification = options.notification 15 | 16 | @timeline = new App.Collections.Timeline([], subject: @) 17 | @comments = new App.Collections.Comments([], subject: @) 18 | @events = new App.Collections.Events([], subject: @) 19 | 20 | @once 'change', -> 21 | @isReady = true 22 | @timeline.add @ 23 | 24 | @comments.url = @get('comments_url') 25 | @timeline.observe @comments 26 | 27 | @events.url = @get('events_url') 28 | @timeline.observe @events 29 | 30 | isUnread: -> 31 | @isUnreadSince(@get('created_at')) 32 | 33 | isUnreadSince: (timestamp) -> 34 | !@get('last_read_at') || moment(@get('last_read_at')) < moment(timestamp) 35 | 36 | toJSON: -> 37 | _.extend super, octicon: @octicon, display_type: @display_type 38 | 39 | # Execute the callback function when the subject is loaded. 40 | ready: (fn) -> 41 | if @isReady 42 | fn.call(@) 43 | else 44 | @once 'change', fn 45 | -------------------------------------------------------------------------------- /app/js/models/subject/commit.coffee: -------------------------------------------------------------------------------- 1 | # See http://developer.github.com/v3/git/commits/#get-a-commit 2 | class App.Models.Subject.Commit extends App.Models.Subject 3 | octicon: 'git-commit' 4 | 5 | createdAt: -> 6 | @get('commit')?.author.date || @notification.get('updated_at') 7 | 8 | isUnread: -> 9 | @isUnreadSince(@createdAt()) 10 | -------------------------------------------------------------------------------- /app/js/models/subject/issue.coffee: -------------------------------------------------------------------------------- 1 | # See http://developer.github.com/v3/issues/ 2 | class App.Models.Subject.Issue extends App.Models.Subject 3 | octicon: 'issue-opened' 4 | display_type: 'issue' 5 | -------------------------------------------------------------------------------- /app/js/models/subject/pull_request.coffee: -------------------------------------------------------------------------------- 1 | class App.Models.Subject.PullRequest extends App.Models.Subject 2 | octicon: 'git-pull-request' 3 | display_type: 'pull request' 4 | 5 | initialize: -> 6 | super 7 | 8 | @on 'change', -> 9 | @events.url = @get('issue_url') + '/events' 10 | 11 | toJSON: -> 12 | attrs = super 13 | attrs.state = 'merged' if attrs.merged 14 | attrs 15 | -------------------------------------------------------------------------------- /app/js/models/subject/release.coffee: -------------------------------------------------------------------------------- 1 | # See http://developer.github.com/v3/repos/releases/ 2 | class App.Models.Subject.Release extends App.Models.Subject 3 | octicon: 'tag' 4 | 5 | toJSON: -> 6 | html_url = @notification.get('repository').html_url 7 | _.extend super, tag_html_url: "#{html_url}/tree/#{@get('tag_name')}" 8 | 9 | hideInTimeline: -> true 10 | -------------------------------------------------------------------------------- /app/js/models/subscription.coffee: -------------------------------------------------------------------------------- 1 | # See http://developer.github.com/v3/activity/notifications/#get-a-thread-subscription 2 | class App.Models.Subscription extends Backbone.Model 3 | defaults: 4 | ignored: false 5 | 6 | initialize: -> 7 | @url = @get('url') 8 | 9 | mute: -> 10 | @save 'ignored', true, {attrs: {'ignored': true}} 11 | 12 | unmute: -> 13 | @save 'ignored', false, {attrs: {'ignored': false}} 14 | 15 | toggle: -> 16 | if @get('ignored') then @unmute() else @mute() 17 | -------------------------------------------------------------------------------- /app/js/models/token.coffee: -------------------------------------------------------------------------------- 1 | # An access token acquired via OAuth 2 | class App.Models.Token 3 | @localStorage: window.localStorage 4 | 5 | @get: => 6 | @localStorage['token'] 7 | 8 | @set: (token) => 9 | @localStorage['token'] = token 10 | 11 | -------------------------------------------------------------------------------- /app/js/native.coffee: -------------------------------------------------------------------------------- 1 | # Hook for top secret native app. Ssshhh, don't tell anyone. 2 | window.require && require('native') 3 | -------------------------------------------------------------------------------- /app/js/routes/filters.coffee: -------------------------------------------------------------------------------- 1 | class App.Routers.Filters extends Backbone.Router 2 | routes: 3 | 'r/:id': 'repository' 4 | 5 | initialize: (options) -> 6 | @filters = options.filters 7 | @vent = options.vent 8 | 9 | @route(new RegExp("^(#{@filters.pluck('id').join('|')})$"), 'filter') 10 | @route(/^([\w\.\-]+\/[\w\.\-]+)$/, 'repository') 11 | 12 | @listenTo @vent, 'filter:selected', (model) => 13 | @navigate "##{model.id}" if model 14 | 15 | @listenTo @vent, 'repository:selected', (model) => 16 | @navigate "#/#{model.get('full_name')}" if model 17 | 18 | filter: (id) -> 19 | @vent.trigger 'filter:select', id 20 | 21 | repository: (full_name) -> 22 | @vent.trigger 'repository:select', full_name 23 | -------------------------------------------------------------------------------- /app/js/routes/misc.coffee: -------------------------------------------------------------------------------- 1 | class App.Routers.Misc extends Backbone.Router 2 | routes: 3 | 'feedback': 'feedback' 4 | 5 | feedback: -> 6 | new App.Views.Feedback().render() 7 | -------------------------------------------------------------------------------- /app/js/routes/notifications.coffee: -------------------------------------------------------------------------------- 1 | class App.Routers.Notifications extends Backbone.Router 2 | routes: 3 | 'n/:id': 'show' 4 | 5 | initialize: (@vent) -> 6 | @listenTo @vent, 'notification:selected', (model) => @navigate "#n/#{model.id}" if model 7 | @listenTo @vent, 'notification:unselected', (model, selectedAnotherModel) => 8 | @navigate "#n" unless selectedAnotherModel 9 | 10 | show: (id) -> 11 | @vent.trigger 'notification:select', id 12 | -------------------------------------------------------------------------------- /app/js/views/banner.coffee: -------------------------------------------------------------------------------- 1 | # A view displayed above all the comments for a Subject 2 | class App.Views.Banner extends App.Views.Comment 3 | className: 'conversation-banner conversation-item' 4 | 5 | # Initialize a banner view with the given template 6 | # 7 | # template - defined by subject. See Models.Subject.Issue#banner, 8 | # Models.PullRequest#banner, and Models.Commit#banner 9 | initialize: (options) -> 10 | @template = options.template 11 | @model.ready @render 12 | 13 | # Ignore all events defined by the Comment view 14 | events: {} 15 | 16 | # Avoid selection state inherited from the comment view 17 | selected: null 18 | unselected: null 19 | -------------------------------------------------------------------------------- /app/js/views/comment.coffee: -------------------------------------------------------------------------------- 1 | # A single Comment view 2 | class App.Views.Comment extends Backbone.View 3 | template: JST['app/templates/comment.us'] 4 | className: 'conversation-comment conversation-item' 5 | 6 | keyboardEvents: 7 | 'space': 'toggle' 8 | 9 | events: 10 | 'click .conversation-meta': 'toggle' 11 | 'focusin': -> @model.select() 12 | 'focusout': -> @model.unselect() 13 | 14 | # Initialize the view 15 | # 16 | # Required options: 17 | # model - a comment 18 | initialize: (options) -> 19 | @listenTo @model, 'selected', @selected 20 | @listenTo @model, 'unselected', @unselected 21 | 22 | render: => 23 | @$el.html @template(App.Views.Helpers.extend(@model.toJSON())) 24 | @$el.addClass if @model.isUnread() then 'expanded' else 'collapsed' 25 | @$el.attr('tabindex', 0) # Make it focusable 26 | app.trigger 'render', @ 27 | @ 28 | 29 | # Toggle the expanded or collapsed state of the comment 30 | toggle: (e) -> 31 | return if $(e.target).is('a') 32 | e.preventDefault() 33 | @$el.toggleClass('collapsed expanded') 34 | 35 | # This comment was selected 36 | selected: (model, previous, options = {}) -> 37 | @bindKeyboardEvents(force = true) 38 | @$el.addClass('selected') 39 | @scrollIntoView(previous) if options.scroll 40 | 41 | # This comment was unselected 42 | unselected: -> 43 | @unbindKeyboardEvents() 44 | @$el.removeClass('selected') 45 | 46 | # Only bind keyboard events when forced to, such as when the model is selected. 47 | bindKeyboardEvents: (force) -> 48 | super() if force 49 | 50 | # Scroll the comment into view. 51 | # 52 | # previous - the comment that was previously selected. 53 | scrollIntoView: (previous) -> 54 | if previous 55 | # Scroll into view if a previous comment was already selected 56 | @$el.scrollIntoView(50) 57 | else if @model != @model.collection.first() 58 | # Scroll to top if no previous comment was selected 59 | @$el.closest('.subject').prop 'scrollTop', @$el.position().top 60 | -------------------------------------------------------------------------------- /app/js/views/create_comment.coffee: -------------------------------------------------------------------------------- 1 | # Form to create a new comment 2 | class App.Views.CreateComment extends Backbone.View 3 | template: JST['app/templates/create_comment.us'] 4 | className: 'write-content conversation-comment conversation-item write-selected' 5 | 6 | class Buttons extends Backbone.View 7 | template: JST['app/templates/create_comment_buttons.us'] 8 | initialize: -> 9 | @listenTo @model, 'change:state', @render 10 | @render() 11 | 12 | render: -> 13 | @$el.html @template(state: @model.get('state')) 14 | 15 | keyboardEvents: 16 | 'meta+enter': 'create' 17 | 18 | events: 19 | 'focusin': -> @collection.select null 20 | 'change form': 'trackDirty' 21 | 'keyup form': 'trackDirty' 22 | 'click .preview-tab': 'preview' 23 | 'click .write-tab': 'write' 24 | 'click button[name=open]': 'open' 25 | 'click button[name=close]': 'close' 26 | 'submit form': 'create' 27 | 28 | # Initialize the comment form 29 | # 30 | # Required options: 31 | # collection: The collection to create the new comment on. 32 | initialize: -> 33 | @render() 34 | 35 | render: => 36 | @$el.html @template(@collection.subject.toJSON()) 37 | @$('.form-actions').html(new Buttons(model: @collection.subject).el) 38 | @form = @$('form') 39 | @previewContent = @$('.comment-body') 40 | @body = @$('[name=body]') 41 | 42 | create: (e) -> 43 | e.preventDefault() 44 | return if $.trim(@body.val()) == '' 45 | @collection.create( 46 | {body: @body.val()} 47 | wait: true 48 | success: @render 49 | error: @error 50 | ) 51 | 52 | open: (e) -> 53 | @create(e) 54 | @collection.subject.save({state:'open'}, {patch:true}) 55 | 56 | close: (e) -> 57 | @create(e) 58 | @collection.subject.save({state:'closed'}, {patch:true}) 59 | 60 | write: (e) -> 61 | e.preventDefault(); 62 | @$el.addClass('write-selected').removeClass('preview-selected') 63 | 64 | preview: (e) => 65 | e.preventDefault() 66 | @$el.removeClass('write-selected').addClass('preview-selected') 67 | @previewContent.html '

Loading preview…

' 68 | 69 | data = 70 | context: @collection.subject.notification.get('repository').full_name 71 | mode: 'gfm' 72 | text: @body.val() 73 | 74 | $.ajax( 75 | type: 'POST', 76 | url: app.endpoints.api + 'markdown', 77 | data: JSON.stringify(data), 78 | ).done(@showPreview) 79 | 80 | showPreview: (body) => 81 | @previewContent.html body || '

Nothing to preview.

' 82 | 83 | error: (comment, xhr, options) => 84 | @$el.addClass('error') 85 | 86 | trackDirty: -> 87 | if $.trim(@body.val()) == '' 88 | @form.removeClass('commenting') 89 | else 90 | @form.addClass('commenting') 91 | -------------------------------------------------------------------------------- /app/js/views/feedback.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Feedback extends Backbone.View 2 | template: JST['app/templates/feedback.us'] 3 | confirmTemplate: JST['app/templates/feedback_confirm.us'] 4 | 5 | events: 6 | 'submit form': 'submit' 7 | 'click .close': 'remove' 8 | 'click .overlay': 'closeIfClickedOutside' 9 | 10 | keyboardEvents: 11 | 'esc': 'remove' 12 | 'meta+enter': 'submit' 13 | 14 | initialize: -> 15 | @model = new App.Models.Feedback 16 | @listenTo @model, 'sync', @confirm 17 | 18 | render: -> 19 | @$el.html @template() 20 | $(document.body).append @el 21 | 22 | submit: (e) -> 23 | e.preventDefault() if e 24 | @model.save title: @$('[name=title]').val(), body: @$('[name=body]').val() 25 | 26 | confirm: => 27 | @$el.html @confirmTemplate(@model.toJSON()) 28 | 29 | remove: (e) => 30 | e.preventDefault() if e 31 | window.history.back() 32 | super 33 | 34 | closeIfClickedOutside: (e) => 35 | @remove(e) if $(e.target).is('.overlay') 36 | -------------------------------------------------------------------------------- /app/js/views/filter.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Filter extends Backbone.View 2 | template: _.template(' <%- name %>') 3 | className: 'list' 4 | tagName: 'li' 5 | 6 | initialize: (options) -> 7 | @listenTo @model, 'unselected', @unselected 8 | @listenTo @model, 'selected', @selected 9 | 10 | render: => 11 | @$el.html @template(@model.toJSON()) 12 | @selected() if @model.isSelected() 13 | app.trigger 'render', @ 14 | @ 15 | 16 | unselected: -> 17 | @$('a').removeClass('selected') 18 | 19 | selected: -> 20 | @$('a').addClass('selected').scrollIntoView(100) 21 | -------------------------------------------------------------------------------- /app/js/views/filters.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Filters extends Backbone.View 2 | el: '#filters' 3 | 4 | initialize: -> 5 | @addAll() 6 | 7 | add: (model) -> 8 | view = new App.Views.Filter(model: model) 9 | @$el.append view.render().el 10 | 11 | addAll: -> 12 | @$el.empty() 13 | @collection.each(@add, @) 14 | -------------------------------------------------------------------------------- /app/js/views/helpers.coffee: -------------------------------------------------------------------------------- 1 | App.Views.Helpers = 2 | # Create a new object that extends these helpers and then the given object. 3 | # 4 | # JST["template"](App.Views.Helpers.extend(@model.toJSON())) 5 | # 6 | extend: (object) -> 7 | _.extend {}, @, object 8 | 9 | pluralize: (count, noun, showCount = true) -> 10 | noun = "#{noun}s" unless count == 1 11 | if showCount 12 | "#{count} #{noun}" 13 | else 14 | noun 15 | 16 | shortSha: (sha) -> 17 | sha.substring(0, 8) 18 | -------------------------------------------------------------------------------- /app/js/views/lists.coffee: -------------------------------------------------------------------------------- 1 | # View all or participating or notifications by repository 2 | class App.Views.Lists extends Backbone.View 3 | el: '#lists' 4 | template: JST['app/templates/lists.us'] 5 | 6 | # Initialize the view 7 | # 8 | # Required options: 9 | # repositories - a collection of repositories to render 10 | initialize: (options) -> 11 | @render() 12 | @repositories = new App.Views.Repositories(collection: options.repositories) 13 | @filters = new App.Views.Filters(collection: options.filters) 14 | 15 | new App.Views.Tips() 16 | 17 | render: -> 18 | @$el.html @template() 19 | app.trigger 'render', @ 20 | @ 21 | -------------------------------------------------------------------------------- /app/js/views/notification.coffee: -------------------------------------------------------------------------------- 1 | # The view for a single notification in the list 2 | class App.Views.Notification extends Backbone.View 3 | tagName: 'li' 4 | className: 'notification' 5 | template: JST['app/templates/notification.us'] 6 | 7 | # Required options: 8 | # model - a notification object 9 | initialize: -> 10 | @listenTo @model, 'unselected', @unselected 11 | @listenTo @model, 'selected', @selected 12 | @listenTo @model, 'change', @render 13 | 14 | render: -> 15 | @$el.html @template(@model.toJSON()) 16 | @selected() if @model.isSelected() 17 | app.trigger 'render', @ 18 | @ 19 | 20 | unselected: -> 21 | @$el.removeClass('selected') 22 | 23 | selected: -> 24 | @$el.addClass('selected').scrollIntoView(100) 25 | -------------------------------------------------------------------------------- /app/js/views/notification_details.coffee: -------------------------------------------------------------------------------- 1 | # Root view for all the details of a notification 2 | class App.Views.NotificationDetailsView extends Backbone.View 3 | template: JST['app/templates/notification_details.us'] 4 | className: 'details pane' 5 | 6 | keyboardEvents: 7 | 'm': -> @model.subscription.toggle() 8 | 'M': 'muteAndNext' 9 | 'o': 'open' 10 | 'r': 'reply' 11 | 12 | events: 13 | 'click a': 'clickLink' 14 | 'click *[rel=back]': 'unfocus' 15 | 16 | # Required options: 17 | # model - a notification object 18 | initialize: -> 19 | view = App.Views.Subject.for(@model.subject) 20 | @subject = new view(model: @model.subject, notification: @model) 21 | @subscription = new App.Views.Subscription(model: @model.subscription) 22 | @render() 23 | 24 | render: -> 25 | @$el.html @template() 26 | @$('.actions').append @subscription.el 27 | @$el.append @subject.el 28 | @ 29 | 30 | # Set target=_blank if it is an external link 31 | clickLink: (e) -> 32 | href = e.target.getAttribute('href') 33 | e.target.target = '_blank' if href && href.hostname != window.location.hostname 34 | 35 | unfocus: (e) -> 36 | e.preventDefault() 37 | @model.unselect() 38 | 39 | muteAndNext: -> 40 | @model.subscription.toggle() 41 | @model.collection.next()?.select() 42 | 43 | # Go to the page on GitHub for this notification 44 | open: (e) -> 45 | e.preventDefault() 46 | window.open @subject.url(), '_blank' 47 | 48 | # Focus the reply textarea 49 | reply:(e) -> 50 | e.preventDefault() 51 | @$('textarea').focus() 52 | 53 | hide: -> 54 | @unbindKeyboardEvents() 55 | @subject.hide() 56 | @$el.removeClass('selected') 57 | 58 | show: -> 59 | @bindKeyboardEvents() 60 | @subject.show() 61 | @$el.addClass('selected') 62 | -------------------------------------------------------------------------------- /app/js/views/repositories.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Repositories extends Backbone.View 2 | el: '#repositories' 3 | 4 | initialize: => 5 | @listenTo @collection, 'add', @add 6 | @listenTo @collection, 'reset', @addAll 7 | 8 | add: (repository) -> 9 | view = new App.Views.Repository(model: repository) 10 | @$el.append view.render().el 11 | 12 | addAll: -> 13 | @$el.empty() 14 | @collection.each(@add, @) 15 | -------------------------------------------------------------------------------- /app/js/views/repository.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Repository extends Backbone.View 2 | template: JST['app/templates/repository.us'] 3 | className: 'list' 4 | tagName: 'li' 5 | 6 | # Initialize the view 7 | # 8 | # Required options: 9 | # model - a comment 10 | initialize: (options) -> 11 | @listenTo @model, 'change', @render 12 | @listenTo @model, 'unselected', @unselected 13 | @listenTo @model, 'selected', @selected 14 | 15 | render: => 16 | @$el.html @template(@model.toJSON()) 17 | @selected() if @model.isSelected() 18 | app.trigger 'render', @ 19 | @ 20 | 21 | unselected: -> 22 | @$('a').removeClass('selected') 23 | 24 | selected: -> 25 | @$('a').addClass('selected').scrollIntoView(100) 26 | -------------------------------------------------------------------------------- /app/js/views/shortcuts.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Shortcuts extends Backbone.View 2 | template: JST['app/templates/shortcuts.us'] 3 | 4 | shortcuts: 5 | 'Next Notification': 6 | keys: ['j', 'down'] 7 | action: 'next' 8 | 9 | 'Previous Notification': 10 | keys: ['k', 'up'] 11 | action: 'prev' 12 | 13 | 'Go to Everything': 14 | key: 'g e' 15 | action: -> Backbone.history.navigate 'everything', trigger: true 16 | 17 | 'Go to Participating': 18 | key: 'g p' 19 | action: -> Backbone.history.navigate 'participating', trigger: true 20 | 21 | 'Go to Mentioned': 22 | key: 'g m' 23 | action: -> Backbone.history.navigate 'mentioned', trigger: true 24 | 25 | 'Open help for keyboard shortcuts': 26 | key: '?' 27 | action: 'help' 28 | 29 | 'Next Repository': 30 | key: 'J' 31 | action: 'nextRepo' 32 | 33 | 'Previous Repository': 34 | key: 'K' 35 | action: 'prevRepo' 36 | 37 | 'Send Feedback': 38 | key: '!' 39 | action: -> Backbone.history.navigate 'feedback', trigger: true 40 | 41 | events: 42 | 'click .close': 'help' 43 | 'click .overlay': 'closeIfClickedOutside' 44 | 45 | # Put undocumented shortcuts here 46 | keyboardEvents: 47 | 'ctrl+`': 'toggleDevelopmentMode' 48 | 49 | initialize: (options) -> 50 | @setElement document.body # otherwise it won't capture all the shortcuts 51 | 52 | @repositories = options.repositories 53 | @vent = options.vent 54 | 55 | for description, options of @shortcuts 56 | options.keys ||= [options.key] 57 | @keyboardEvents[key] = options.action for key in options.keys 58 | 59 | @render() 60 | 61 | next: (e) -> 62 | e.preventDefault() 63 | @vent.trigger 'notification:next' 64 | 65 | prev: (e) -> 66 | e.preventDefault() 67 | @vent.trigger 'notification:prev' 68 | 69 | nextRepo: (e) -> 70 | e.preventDefault() 71 | @vent.trigger 'repo:next' 72 | repo = @repositories.next() || @repositories.first() 73 | repo?.select() 74 | 75 | prevRepo: (e) -> 76 | e.preventDefault() 77 | @vent.trigger 'repo:prev' 78 | repo = @repositories.prev() || @repositories.last() 79 | repo?.select() 80 | 81 | select: (notification) -> 82 | notification?.select() 83 | 84 | render: -> 85 | @$('#shortcuts').html(@template(@)) 86 | @ 87 | 88 | help: -> 89 | @$('#shortcuts').toggle() 90 | 91 | closeIfClickedOutside: (e) => 92 | @help() if $(e.target).is('.overlay') 93 | 94 | toggleDevelopmentMode: -> 95 | app.toggleDevelopment() 96 | -------------------------------------------------------------------------------- /app/js/views/subject.coffee: -------------------------------------------------------------------------------- 1 | # Renders the subject of a notification. 2 | # 3 | # This view is responsible for showing most of the relevant details of the 4 | # thing that the notification is for, which should be an Issue, PullRequest, 5 | # or Commit. 6 | class App.Views.Subject extends Backbone.View 7 | template: JST['app/templates/subject.us'] 8 | className: 'subject content loading' 9 | 10 | # Chose the appropriate view class for the given subject 11 | @for: (model) -> 12 | App.Views.Subject[model.get('type')] || App.Views.Subject.Unknown 13 | 14 | # Required options: 15 | # notification - a Notification model 16 | # model - a Subject model 17 | initialize: (options) -> 18 | @notification = options.notification 19 | 20 | @bannerView = new App.Views.Banner(model: @model, template: @banner) if @banner 21 | @timelineView = new App.Views.Timeline(collection: @model.timeline) 22 | 23 | @listenTo @model, 'change', => @model.timeline.fetch() 24 | 25 | @render() 26 | 27 | @model.ready @loaded 28 | @model.fetch() if @model.url 29 | 30 | # Show loader while timeline is loading 31 | @listenTo @model.timeline, 'request', @startFetching 32 | @listenTo @model.timeline, 'sync error', @doneFetching 33 | @fetchCount = 0 34 | 35 | render: -> 36 | @$el.html @template(@model.toJSON()) 37 | @$('.comments').append(@bannerView.el) if @banner 38 | @$('.comments').append(@timelineView.el) 39 | app.trigger 'render', @ 40 | 41 | loaded: => 42 | if @model.comments.url 43 | @$el.append new App.Views.CreateComment(collection: @model.comments).el 44 | 45 | @$el.removeClass('loading') 46 | 47 | hide: -> 48 | @timelineView.unbindKeyboardEvents() 49 | 50 | show: -> 51 | @timelineView.bindKeyboardEvents() 52 | 53 | url: -> 54 | if unread = @model.comments.detect((comment) -> comment.isUnread()) 55 | unread.get('html_url') 56 | else 57 | @model.get('html_url') 58 | 59 | startFetching: (object) -> 60 | # We only care about requests for the collection 61 | return unless object instanceof Backbone.Collection 62 | 63 | @fetchCount += 1 64 | @$el.addClass('paginating') 65 | 66 | doneFetching: (object) -> 67 | # We only care about requests for the collection 68 | return unless object instanceof Backbone.Collection 69 | 70 | @fetchCount -= 1 71 | @$el.removeClass('paginating') if @fetchCount == 0 72 | -------------------------------------------------------------------------------- /app/js/views/subject/commit.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Subject.Commit extends App.Views.Subject 2 | banner: JST['app/templates/subject/commit.us'] 3 | -------------------------------------------------------------------------------- /app/js/views/subject/issue.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Subject.Issue extends App.Views.Subject 2 | banner: JST['app/templates/subject/issue.us'] 3 | -------------------------------------------------------------------------------- /app/js/views/subject/pull_request.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Subject.PullRequest extends App.Views.Subject 2 | banner: JST['app/templates/subject/pull_request.us'] 3 | -------------------------------------------------------------------------------- /app/js/views/subject/release.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Subject.Release extends App.Views.Subject 2 | banner: JST['app/templates/subject/release.us'] 3 | 4 | render: -> 5 | result = super 6 | @loaded() 7 | result 8 | -------------------------------------------------------------------------------- /app/js/views/subject/unknown.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Subject.Unknown extends Backbone.View 2 | template: JST['app/templates/subject/unknown.us'] 3 | className: 'subject content' 4 | 5 | initialize: -> 6 | @$el.html @template(url: @url()) 7 | 8 | url: -> 9 | @model.get('html_url') || @model.notification.get('repository').html_url + '/notifications' 10 | 11 | show: -> 12 | 13 | hide: -> 14 | -------------------------------------------------------------------------------- /app/js/views/subscription.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Subscription extends Backbone.View 2 | template: JST['app/templates/subscription.us'] 3 | tagName: 'span' 4 | className: 'subscription' 5 | 6 | events: 7 | 'click .mute': -> @model.mute() 8 | 'click .unmute': -> @model.unmute() 9 | 10 | initialize: -> 11 | # Results in a 404 if a subscription doesn't exist for this thread. 12 | @model.fetch() 13 | @listenTo @model, 'change', @render 14 | @render() 15 | 16 | render: -> 17 | @$el.html @template(@model.toJSON()) 18 | @ 19 | -------------------------------------------------------------------------------- /app/js/views/threads.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Threads extends Backbone.View 2 | template: JST['app/templates/threads.us'] 3 | className: 'loading' 4 | 5 | events: 6 | 'change input[name=notifications-state]': 'stateChange' 7 | 'click #mark-all-read': 'read' 8 | 9 | initialize: -> 10 | @listenTo @collection, 'add', @add 11 | @listenTo @collection, 'reset', @addAll 12 | @listenTo @collection, 'change:updated_at', @queueForSort 13 | 14 | @listenTo @collection, 'request', @startPaginating 15 | @listenTo @collection, 'sync error', @donePaginating 16 | 17 | @views = {} 18 | 19 | render: -> 20 | @$el.html @template() 21 | 22 | # Bind to scroll and non-standard mouse events to enable loading more when 23 | # content is not scrollable, such as when there are no notifications. 24 | @$content = @$('.content').on('scroll mousewheel DOMMouseScroll', _.debounce(@loadMore, 50)) 25 | 26 | app.trigger 'render', @ 27 | @$list = @$('.notification-list') 28 | @ 29 | 30 | add: (model) -> 31 | # memoize view instances 32 | view = @views[model.id] ||= new App.Views.Notification(model: model).render() 33 | 34 | # Maintain view order by inserting it the new element before the element 35 | # that is currently in its place. 36 | sibling = @$list.children().eq(@collection.indexOf(model)) 37 | if sibling.length 38 | sibling.before(view.el) 39 | else 40 | @$list.append(view.el) 41 | 42 | addAll: -> 43 | @$list.empty() 44 | @collection.each(@add, @) 45 | 46 | # When the collection is updated, the "change" event is fired on existing 47 | # models before the collection is sorted, so we wait for the "sort" event 48 | # before attempting to sort the views. 49 | queueForSort: (model) -> 50 | @collection.once 'sort', => @add(model) 51 | 52 | read: (e) -> 53 | e.preventDefault() 54 | if window.confirm("Are you sure you want to mark all these as read?") 55 | @collection.read() 56 | 57 | shouldShowAll: -> 58 | @$('input[name=notifications-state]:checked').val() == 'all' 59 | 60 | stateChange: -> 61 | @$el.addClass('loading') 62 | @collection.data.all = @shouldShowAll() 63 | @collection.fetch(reset: true).then(@loadMore) 64 | 65 | loadMore: => 66 | return if @isLoading 67 | 68 | if @shouldPoll() 69 | @collection.poll() 70 | else if !@collection.donePaginating && @shouldPaginate() 71 | @collection.paginate().done(@loadMore) 72 | 73 | shouldPoll: -> 74 | @$content.scrollTop() == 0 75 | 76 | shouldPaginate: -> 77 | @$content.children().height() - @$content.scrollTop() < @$content.height() + 300 78 | 79 | hide: -> 80 | @$el.detach() 81 | @collection.stopPolling(); 82 | 83 | show: -> 84 | @collection.data.all = @shouldShowAll() 85 | @collection.poll(); 86 | 87 | startPaginating: (object) -> 88 | # Ignore model events 89 | return unless object == @collection 90 | 91 | @isLoading = true 92 | @$el.addClass('paginating') 93 | 94 | donePaginating: (object) -> 95 | # Ignore model events 96 | return unless object == @collection 97 | 98 | @isLoading = false 99 | @$el.removeClass('loading paginating') 100 | -------------------------------------------------------------------------------- /app/js/views/timeline.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Timeline extends Backbone.View 2 | keyboardEvents: 3 | 'n': 'selectNext' 4 | 'p': 'selectPrevious' 5 | 6 | initialize: -> 7 | @listenTo @collection, 'add', @add 8 | @listenTo @collection, 'reset', @addAll 9 | @collection.on 'add sync', @scroll 10 | @addAll() 11 | 12 | add: (model) -> 13 | view = @viewFor(model) 14 | return unless view 15 | 16 | view.render() 17 | 18 | sibling = @$el.children().eq(@collection.indexOf(model)) 19 | 20 | if sibling.length 21 | sibling.before(view.el) 22 | else 23 | @$el.append(view.el) 24 | 25 | addAll: -> 26 | @collection.each(@add, @) 27 | 28 | # Scroll to the first unread item 29 | scroll: => 30 | return if @collection.selected 31 | if unread = @collection.detect((model) -> model.isUnread()) 32 | @collection.select unread, scroll: true 33 | 34 | viewFor: (model) -> 35 | view = if model instanceof App.Models.Event 36 | App.Views["TimelineEvent"] 37 | else if model instanceof App.Models.Subject.Commit 38 | App.Views.Timeline.Commit 39 | else if model.get('body_html') 40 | App.Views.Comment 41 | 42 | new view(model: model) if view 43 | 44 | selectNext: -> 45 | item = if @collection.selected 46 | @collection.next() 47 | else 48 | @collection.first() 49 | 50 | @collection.select item, scroll: true if item 51 | 52 | selectPrevious: -> 53 | item = if @collection.selected 54 | @collection.prev() 55 | else 56 | @collection.last() 57 | 58 | @collection.select item, scroll: true if item 59 | -------------------------------------------------------------------------------- /app/js/views/timeline/commit.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.Timeline.Commit extends App.Views.Comment 2 | template: JST["app/templates/timeline/commit.us"] 3 | -------------------------------------------------------------------------------- /app/js/views/timeline/event.coffee: -------------------------------------------------------------------------------- 1 | class App.Views.TimelineEvent extends Backbone.View 2 | className: 'conversation-event conversation-item conversation-content' 3 | 4 | render: => 5 | @$el.addClass("conversation-event-#{@model.get('event')}") 6 | 7 | template = JST["app/templates/timeline/#{@model.get('event')}.us"] 8 | 9 | data = @model.toJSON() 10 | data.subject = @model.collection.subject.toJSON() 11 | # FIXME: YUCK 12 | data.repository_html_url = @model.collection.subject.notification.get('repository').html_url 13 | @$el.html template(data) 14 | app.trigger 'render', @ 15 | @ 16 | -------------------------------------------------------------------------------- /app/js/views/tips.coffee: -------------------------------------------------------------------------------- 1 | # Random tips 2 | class App.Views.Tips extends Backbone.View 3 | el: '#tips' 4 | template: JST['app/templates/tips.us'] 5 | 6 | tips: [ 7 | "Hit the ? key for all keyboard awesomeness." 8 | "You can reply to comments below the notifications." 9 | ] 10 | 11 | # Initialize the view 12 | initialize: -> 13 | @render() 14 | 15 | render: -> 16 | @$el.html @template(tip: @random()) 17 | app.trigger 'render', @ 18 | @ 19 | 20 | random: -> 21 | @tips[Math.floor(Math.random() * @tips.length)] 22 | -------------------------------------------------------------------------------- /app/pages/index.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GitHub Notifications 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 37 | 39 | 40 | 43 | 44 | 45 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /app/templates/comment.us: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%- user.login %> 5 | 6 | 7 | 8 |
9 | 10 |
<%= body_html %>
11 | -------------------------------------------------------------------------------- /app/templates/create_comment.us: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | 8 |
9 |
10 |
There was an error creating your comment.
11 | 12 |
13 | 14 |
15 |
16 |
17 |

Nothing to preview

18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /app/templates/create_comment_buttons.us: -------------------------------------------------------------------------------- 1 | <% if(state == 'open') { %> 2 | 3 | 4 | <% } else if(state == 'closed') { %> 5 | 6 | 7 | <% } %> 8 | 9 | -------------------------------------------------------------------------------- /app/templates/feedback.us: -------------------------------------------------------------------------------- 1 |
2 | 14 |
15 | -------------------------------------------------------------------------------- /app/templates/feedback_body.us: -------------------------------------------------------------------------------- 1 | <%= body %> 2 | 3 | 4 | 5 | 6 | 7 | 8 |
Browser<%- navigator.userAgent %>
9 | -------------------------------------------------------------------------------- /app/templates/feedback_confirm.us: -------------------------------------------------------------------------------- 1 |
2 | 12 |
13 | -------------------------------------------------------------------------------- /app/templates/lists.us: -------------------------------------------------------------------------------- 1 |
2 |

GitHub Notifications

3 |
4 |
5 |
    6 |
7 | 8 |

Repositories

9 |
    10 |
11 | 12 | Send Feedback 13 | 14 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /app/templates/notification.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- subject.title %> 4 | <%- repository.owner.login %> / <%- repository.name %> 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/templates/notification_details.us: -------------------------------------------------------------------------------- 1 |
2 | 3 | 5 |
6 | -------------------------------------------------------------------------------- /app/templates/repository.us: -------------------------------------------------------------------------------- 1 | 2 | <%- full_name %> 3 | <%- unread_count %> 4 | 5 | -------------------------------------------------------------------------------- /app/templates/shortcuts.us: -------------------------------------------------------------------------------- 1 |
2 | 12 |
13 | -------------------------------------------------------------------------------- /app/templates/subject.us: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | -------------------------------------------------------------------------------- /app/templates/subject/commit.us: -------------------------------------------------------------------------------- 1 |

2 | 3 | <%- title %> 4 | <%- sha.substring(0, 8) %> 5 | 6 |

7 | 8 |
9 | 10 | 11 | <%- author.login %> 12 | changed <%- pluralize(files.length, 'file') %> with 13 | <%- pluralize(stats.additions, 'addition') %> and 14 | <%- pluralize(stats.deletions, 'deletion') %>. 15 | 16 |
17 | -------------------------------------------------------------------------------- /app/templates/subject/issue.us: -------------------------------------------------------------------------------- 1 |

2 | 3 | <%- title %> 4 | #<%- number %> 5 | 6 |

7 | 8 |
9 | 10 | 11 | 12 | <%- state %> 13 | 14 | 15 | 16 | 17 | <%- user.login %> 18 | opened this issue 19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /app/templates/subject/pull_request.us: -------------------------------------------------------------------------------- 1 |

2 | 3 | <%- title %> 4 | #<%- number %> 5 | 6 |

7 | 8 |
9 | 10 | 11 | 12 | <%- state %> 13 | 14 | 15 | 16 | 17 | <%- user.login %> 18 | wants to merge <%- commits %> commits into 19 | <%- base.ref %> 20 | from 21 | <%- head.ref %> 22 | 23 |
24 | -------------------------------------------------------------------------------- /app/templates/subject/release.us: -------------------------------------------------------------------------------- 1 |

2 | 3 | <%- title %> 4 | 5 |

6 | 7 |
8 | 9 | 10 | 11 | 12 | <%- author.login %> 13 | 14 | released this 15 | 16 | 17 | 18 |
19 | 20 | 34 | -------------------------------------------------------------------------------- /app/templates/subject/unknown.us: -------------------------------------------------------------------------------- 1 |
2 |

Your super powers don't work here.

3 | 4 |

Your super human strength gets you access to special features on GitHub, but it apparently isn't enough to fabricate API endpoints. Try using Jedi mind tricks on someone that can implement the API.

5 | 6 | View on GitHub.com 7 |
8 | -------------------------------------------------------------------------------- /app/templates/subscription.us: -------------------------------------------------------------------------------- 1 | <% if(ignored) { %> 2 | 3 | <% } else { %> 4 | 5 | <% } %> 6 | -------------------------------------------------------------------------------- /app/templates/threads.us: -------------------------------------------------------------------------------- 1 |
2 | 4 | 11 | 14 |
15 |
16 |
    17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /app/templates/timeline/closed.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- actor.login %> 4 | closed the <%- subject.display_type %> 5 | 6 | -------------------------------------------------------------------------------- /app/templates/timeline/commit.us: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%- author.login %> 5 | 6 | committed 7 | 8 | <%- shortSha(sha) %> 9 | 10 | 11 |
12 | 13 |
14 |
<%- commit.message %>
15 | 16 |
17 | <% // FIXME: move this to its own view %> 18 |
19 | <% _.each(files, function(file) { %> 20 |
21 |
22 | 25 |
26 |
27 |
28 | 29 |
<%- file.patch %>
30 |
31 | <% }) %> 32 |
33 |
34 |
35 | -------------------------------------------------------------------------------- /app/templates/timeline/head_ref_deleted.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- actor.login %> 4 | deleted the <%- subject.head.ref %> branch 5 | 6 | -------------------------------------------------------------------------------- /app/templates/timeline/head_ref_restored.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- actor.login %> 4 | restored the <%- subject.head.ref %> branch 5 | 6 | -------------------------------------------------------------------------------- /app/templates/timeline/merged.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- actor.login %> 4 | merged commit <%- commit_id.substring(0, 8) %> 5 | into <%- subject.base.label %> 6 | from <%- subject.head.label %> 7 | 8 | -------------------------------------------------------------------------------- /app/templates/timeline/referenced.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- actor.login %> 4 | referenced this <%- subject.display_type %> 5 | from commit <%- commit_id.substring(0, 8) %> 6 | 7 | -------------------------------------------------------------------------------- /app/templates/timeline/reopened.us: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%- actor.login %> 4 | reopened the <%- subject.display_type %> 5 | 6 | -------------------------------------------------------------------------------- /app/templates/tips.us: -------------------------------------------------------------------------------- 1 |

2 | 3 | ProTip! 4 | <%= tip %> 5 |

6 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notifications", 3 | "version": "0.0.0", 4 | "dependencies": { 5 | "jquery": "~1.9.0", 6 | "underscore": "~1.4.3", 7 | "backbone": "~1.0.0", 8 | "modernizr": "~2.6.2", 9 | "normalize-css": "~2.1.2", 10 | "moment": "~2.1.0", 11 | "mousetrap": "~1.4.5", 12 | "backbone.mousetrap": "https://github.com/elasticsales/backbone.mousetrap.git", 13 | "jQuery.scrollIntoView": "https://github.com/Arwid/jQuery.scrollIntoView.git", 14 | "fastclick": "~0.6.11", 15 | "octicons": "*" 16 | }, 17 | "devDependencies": { 18 | "primer": "https://github.com/github/primer.git" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /config/application.coffee: -------------------------------------------------------------------------------- 1 | # Exports an object that defines 2 | # all of the configuration needed by the projects' 3 | # depended-on grunt tasks. 4 | # 5 | # You can find the parent object in: node_modules/lineman/config/application.coffee 6 | module.exports = require(process.env["LINEMAN_MAIN"]).config.extend "application", 7 | # Override application configuration here. Common examples follow in the comments. 8 | 9 | removeTasks: 10 | common: ["less"] 11 | -------------------------------------------------------------------------------- /config/defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "oauth_client_id": "7af5378b831dda8f4ae3", 3 | "oauth_client_secret": "32f502eb3ada281bca20683bc82aa1f88fbfaaa7", 4 | "oauth_host": "github.com", 5 | "oauth_port": 443, 6 | "oauth_path": "/login/oauth/access_token", 7 | "oauth_method": "POST", 8 | "oauth_scope": "notifications,repo" 9 | } 10 | -------------------------------------------------------------------------------- /config/files.coffee: -------------------------------------------------------------------------------- 1 | # Exports an object that defines 2 | # all of the paths & globs that the project 3 | # is concerned with. 4 | # 5 | # The "configure" task will require this file and 6 | # then re-initialize the grunt config such that 7 | # directives like will work 8 | # regardless of the point you're at in the build 9 | # lifecycle. 10 | # 11 | # You can find the parent object in: node_modules/lineman/config/files.coffee 12 | module.exports = require(process.env["LINEMAN_MAIN"]).config.extend "files", 13 | coffee: 14 | app: [ 15 | "app/js/lib/*.coffee", 16 | "app/js/app.coffee", 17 | "app/js/models/**/*.coffee", 18 | "app/js/views/comment.coffee", 19 | "app/js/**/*.coffee" 20 | ] 21 | 22 | js: 23 | vendor: [ 24 | "vendor/bower/jquery/jquery.js", 25 | "vendor/bower/underscore/underscore.js", 26 | "vendor/bower/backbone/backbone.js", 27 | "vendor/bower/moment/moment.js", 28 | "vendor/bower/mousetrap/mousetrap.js", 29 | "vendor/bower/backbone.mousetrap/backbone.mousetrap.js", 30 | "vendor/bower/jQuery.scrollIntoView/jquery.scrollIntoView.js", 31 | "vendor/bower/fastclick/lib/fastclick.js", 32 | "vendor/js/**/*.js" 33 | ] 34 | app: ["app/js/**/*.js"] 35 | 36 | css: 37 | vendor: [ 38 | "vendor/bower/octicons/octicons/octicons.css", 39 | "vendor/bower/normalize-css/normalize.css", 40 | "vendor/css/**/*.css" 41 | ] 42 | -------------------------------------------------------------------------------- /config/plugins/bower-custom.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (lineman) -> 2 | lineman.config.application.bower.install.options.bowerOptions = 3 | production: true 4 | {} 5 | -------------------------------------------------------------------------------- /config/plugins/concat-sourcemap.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (lineman) -> 2 | config: 3 | loadNpmTasks: lineman.config.application.loadNpmTasks.concat("grunt-concat-sourcemap") 4 | 5 | appendTasks: 6 | common: lineman.config.application.prependTasks.common.concat("concat_sourcemap") 7 | 8 | removeTasks: 9 | common: lineman.config.application.removeTasks.common.concat("concat") 10 | 11 | concat_sourcemap: 12 | options: 13 | sourcesContent: true 14 | js: 15 | src: [ 16 | "", 17 | "<%= files.js.vendor %>", 18 | "<%= files.template.generated %>", 19 | "<%= files.js.app %>", 20 | "<%= files.coffee.generated %>" 21 | ] 22 | dest: "<%= files.js.concatenated %>" 23 | 24 | spec: 25 | src: [ 26 | "<%= files.js.specHelpers %>", 27 | "<%= files.coffee.generatedSpecHelpers %>", 28 | "<%= files.js.spec %>", 29 | "<%= files.coffee.generatedSpec %>" 30 | ] 31 | dest: "<%= files.js.concatenatedSpec %>" 32 | 33 | css: 34 | src: [ 35 | "<%= files.stylus.generatedVendor %>", 36 | "<%= files.css.vendor %>", 37 | "<%= files.stylus.generatedApp %>", 38 | "<%= files.css.app %>" 39 | ] 40 | dest: "<%= files.css.concatenated %>" 41 | 42 | 43 | -------------------------------------------------------------------------------- /config/plugins/manifest.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (lineman) -> 2 | config: 3 | loadNpmTasks: lineman.config.application.loadNpmTasks.concat("grunt-manifest") 4 | 5 | appendTasks: 6 | dist: lineman.config.application.appendTasks.dist.concat("manifest") 7 | 8 | manifest: 9 | generate: 10 | options: 11 | basePath: './dist' 12 | hash: true 13 | verbose: false 14 | timestamp: false 15 | master: ['index.html'] 16 | src: ["**/*.*"] 17 | dest: 'dist/manifest.appcache' 18 | -------------------------------------------------------------------------------- /config/plugins/octicons.coffee: -------------------------------------------------------------------------------- 1 | # Override lineman's webfonts config to pull fonts from bower 2 | module.exports = (lineman) -> 3 | config: 4 | webfonts: 5 | files: 6 | "vendor/bower/octicons/octicons/": "vendor/bower/octicons/octicons/octicons.{ttf,eot,woff,svg}" 7 | root: "css" 8 | -------------------------------------------------------------------------------- /config/plugins/stylus.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (lineman) -> 2 | config: 3 | loadNpmTasks: lineman.config.application.loadNpmTasks.concat("grunt-contrib-stylus") 4 | 5 | prependTasks: 6 | common: lineman.config.application.prependTasks.common.concat("stylus") 7 | 8 | stylus: 9 | compile: 10 | use: [require("nib")] 11 | src: "app/css/app.styl" 12 | dest: "<%= files.stylus.generatedApp %>" 13 | 14 | files: 15 | stylus: 16 | main: "app/css/main.styl" 17 | vendor: "vendor/css/**/*.styl" 18 | app: "app/css/**/*.styl" 19 | import: "app/css" 20 | generatedVendor: "generated/css/vendor.styl.css" 21 | generatedApp: "generated/css/app.styl.css" 22 | -------------------------------------------------------------------------------- /config/plugins/watch.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (lineman) -> 2 | config: 3 | watch: 4 | js: 5 | files: ["<%= files.js.vendor %>", "<%= files.js.app %>"] 6 | tasks: ["concat_sourcemap:js"] 7 | 8 | coffee: 9 | files: "<%= files.coffee.app %>" 10 | tasks: ["coffee", "concat_sourcemap:js"] 11 | 12 | jsSpecs: 13 | files: ["<%= files.js.specHelpers %>", "<%= files.js.spec %>"] 14 | tasks: ["concat_sourcemap:spec"] 15 | 16 | coffeeSpecs: 17 | files: ["<%= files.coffee.specHelpers %>", "<%= files.coffee.spec %>"] 18 | tasks: ["coffee", "concat_sourcemap:spec"] 19 | 20 | css: 21 | files: ["<%= files.css.vendor %>", "<%= files.css.app %>"] 22 | tasks: ["concat_sourcemap:css"] 23 | 24 | stylus: 25 | files: ["<%= files.stylus.vendor %>", "<%= files.stylus.app %>"] 26 | tasks: ["stylus", "concat_sourcemap:css"] 27 | 28 | handlebars: 29 | tasks: ["handlebars", "concat_sourcemap:js"] 30 | 31 | underscore: 32 | tasks: ["jst", "concat_sourcemap:js"] 33 | -------------------------------------------------------------------------------- /config/server.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | drawRoutes: function(app) { 3 | if(process.env.NODE_ENV == 'production') { 4 | app.use(requireHTTPS); 5 | } 6 | 7 | app.get('/authenticate', function(req, res) { 8 | res.json({client_id: config.oauth_client_id, scope: config.oauth_scope}); 9 | }); 10 | 11 | app.post('/authenticate/:code', function(req, res) { 12 | authenticate(req.params.code, function(err, token) { 13 | var result = err || !token ? {"error": "bad_code"} : {"token": token}; 14 | res.json(result); 15 | }); 16 | }); 17 | } 18 | } 19 | 20 | function requireHTTPS(req, res, next) { 21 | res.setHeader('Strict-Transport-Security', 'max-age=31536000'); 22 | 23 | var isSecure = req.secure || req.headers['x-forwarded-proto'] == 'https'; 24 | 25 | if(isSecure) { 26 | return next(); 27 | } else { 28 | return res.redirect("https://" + req.get('host') + req.url); 29 | } 30 | } 31 | 32 | var url = require('url'), 33 | https = require('https'), 34 | qs = require('querystring'); 35 | 36 | // Load config defaults from JSON file. 37 | // Environment variables override defaults. 38 | function loadConfig() { 39 | var config = JSON.parse(require('fs').readFileSync(__dirname + '/defaults.json', 'utf-8')); 40 | for (var i in config) { 41 | config[i] = process.env[i.toUpperCase()] || config[i]; 42 | } 43 | return config; 44 | } 45 | 46 | var config = loadConfig(); 47 | 48 | function authenticate(code, cb) { 49 | var data = qs.stringify({ 50 | client_id: config.oauth_client_id, 51 | client_secret: config.oauth_client_secret, 52 | code: code 53 | }); 54 | 55 | var reqOptions = { 56 | host: config.oauth_host, 57 | port: config.oauth_port, 58 | path: config.oauth_path, 59 | method: config.oauth_method, 60 | headers: { 61 | 'content-length': data.length 62 | } 63 | }; 64 | 65 | var body = ""; 66 | var req = https.request(reqOptions, function(res) { 67 | res.setEncoding('utf8'); 68 | res.on('data', function(chunk) { body += chunk; }); 69 | res.on('end', function() { cb(null, qs.parse(body).access_token); }); 70 | }); 71 | 72 | req.write(data); 73 | req.end(); 74 | req.on('error', function(e) { cb(e.message); }); 75 | } 76 | -------------------------------------------------------------------------------- /config/spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "framework" : "jasmine", 3 | "launch_in_dev" : ["Chrome"], 4 | "launch_in_ci" : ["PhantomJS"], 5 | "src_files" : [ 6 | "generated/js/app.js", 7 | "generated/js/spec.js" 8 | ] 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-notifiations", 3 | "title": "A client for reading GitHub web notifications", 4 | "repository": { 5 | "type": "git", 6 | "url": "git://github.com/bkeepers/github-notifications.git" 7 | }, 8 | "version": "0.0.1", 9 | "private": true, 10 | "author": { 11 | "name": "Brandon Keepers", 12 | "company": "GitHub" 13 | }, 14 | "engines": { 15 | "node": "0.10.x", 16 | "npm": "1.3.x" 17 | }, 18 | "dependencies": { 19 | "express": "~3.4.4" 20 | }, 21 | "devDependencies": { 22 | "bower": "~1.3.8", 23 | "grunt-bower-task": "0.4.0", 24 | "lineman": ">=0.19.3", 25 | "grunt-concat-sourcemap": "~0.3.0", 26 | "grunt-contrib-stylus": "~0.9.0", 27 | "nib": "~1.0.1", 28 | "lineman-bower": "0.0.3", 29 | "grunt-manifest": "git+https://github.com/gunta/grunt-manifest.git#6b830e31" 30 | }, 31 | "scripts": { 32 | "postinstall": "lineman build" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /script/bootstrap: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npm install 4 | -------------------------------------------------------------------------------- /script/server: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./node_modules/.bin/lineman run 4 | -------------------------------------------------------------------------------- /script/spec: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | ./node_modules/.bin/lineman spec 4 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | 4 | require('./config/server').drawRoutes(app); 5 | 6 | app.use(express.static(__dirname + '/dist')); 7 | app.listen(process.env.PORT || 3000); 8 | -------------------------------------------------------------------------------- /spec/collections/notifications_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Collections.Notifications', -> 2 | context 'with a filter', -> 3 | beforeEach -> 4 | filter = (model) -> model.get('bonafide') 5 | @collection = new App.Collections.Notifications([], filter: filter) 6 | 7 | it 'adds models that satisfy the filter', -> 8 | @collection.add new Backbone.Model(name: 'Vernon T. Waldrip', bonafide: true) 9 | expect(@collection.size()).toBe(1) 10 | 11 | it 'ignores models that do not satisfy the filter', -> 12 | @collection.add new Backbone.Model(name: 'Ulysses Everett McGill', bonafide: false) 13 | expect(@collection.size()).toBe(0) 14 | 15 | it 'marks each notification individually as read', -> 16 | model = new Backbone.Model(name: 'Iliad', bonafide: true) 17 | model.read = jasmine.createSpy('read') 18 | @collection.add(model) 19 | @collection.read() 20 | expect(model.read).toHaveBeenCalled() 21 | 22 | describe 'comparator', -> 23 | it 'orders by updated at', -> 24 | @collection = new App.Collections.Notifications([]) 25 | @collection.add([ 26 | new Backbone.Model({id: 1, updated_at: "2014-09-14"}) 27 | new Backbone.Model({id: 2, updated_at: "2014-09-15"}) 28 | new Backbone.Model({id: 3, updated_at: "2014-09-13"}) 29 | ]) 30 | 31 | expect(@collection.at(0).id).toBe(2) 32 | expect(@collection.at(1).id).toBe(1) 33 | expect(@collection.at(2).id).toBe(3) 34 | -------------------------------------------------------------------------------- /spec/collections/repositories_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Collections.Repositories', -> 2 | context 'findByName', -> 3 | beforeEach -> 4 | @collection = new App.Collections.Repositories([]) 5 | 6 | it 'finds model by name', -> 7 | model = new App.Models.Repository(full_name: 'foo/bar') 8 | @collection.add(model) 9 | expect(@collection.findByName('foo/bar')).toEqual(model) 10 | -------------------------------------------------------------------------------- /spec/collections/timeline_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Collections.Timeline', -> 2 | describe 'comparator', -> 3 | it 'sorts by created_at', -> 4 | @collection = new App.Collections.Timeline 5 | @collection.add new App.Models.Comment(created_at: '2014-02-13') 6 | @collection.add new App.Models.Comment(created_at: '2014-02-12') 7 | @collection.add new App.Models.Comment(created_at: '2014-02-15') 8 | 9 | expect(@collection.first().get('created_at')).toEqual('2014-02-12') 10 | expect(@collection.last().get('created_at')).toEqual('2014-02-15') 11 | 12 | describe 'observe', -> 13 | beforeEach -> 14 | @timeline = new App.Collections.Timeline 15 | @other = new Backbone.Collection() 16 | 17 | @timeline.observe @other 18 | 19 | it 'syncs adds and removes', -> 20 | model = new Backbone.Model 21 | @other.add model 22 | expect(@timeline.size()).toBe(1) 23 | expect(@timeline.first()).toEqual(model) 24 | 25 | @other.remove model 26 | expect(@timeline.size()).toBe(0) 27 | -------------------------------------------------------------------------------- /spec/helpers/helper.js: -------------------------------------------------------------------------------- 1 | var root = this; 2 | 3 | root.context = root.describe; 4 | root.xcontext = root.xdescribe; -------------------------------------------------------------------------------- /spec/helpers/jasmine-fixture.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | jasmine-fixture 1.0.5 4 | Makes injecting HTML snippets into the DOM easy & clean! 5 | site: https://github.com/searls/jasmine-fixture 6 | */ 7 | 8 | 9 | (function() { 10 | var createHTMLBlock; 11 | 12 | (function($) { 13 | var jasmineFixture, originalAffix, originalInject, originalJasmineFixture, root, _; 14 | root = this; 15 | originalJasmineFixture = root.jasmineFixture; 16 | originalInject = root.inject; 17 | originalAffix = root.affix; 18 | _ = function(list) { 19 | return { 20 | inject: function(iterator, memo) { 21 | var item, _i, _len, _results; 22 | _results = []; 23 | for (_i = 0, _len = list.length; _i < _len; _i++) { 24 | item = list[_i]; 25 | _results.push(memo = iterator(memo, item)); 26 | } 27 | return _results; 28 | } 29 | }; 30 | }; 31 | root.jasmineFixture = function($) { 32 | var $whatsTheRootOf, applyAttributes, defaultConfiguration, defaults, init, injectContents, isReady, isString, itLooksLikeHtml, rootId, tidyUp; 33 | $.fn.affix = root.affix = function(selectorOptions) { 34 | var $top; 35 | $top = null; 36 | _(selectorOptions.split(/[ ](?=[^\]]*?(?:\[|$))/)).inject(function($parent, elementSelector) { 37 | var $el; 38 | if (elementSelector === ">") { 39 | return $parent; 40 | } 41 | $el = createHTMLBlock($, elementSelector).appendTo($parent); 42 | $top || ($top = $el); 43 | return $el; 44 | }, $whatsTheRootOf(this)); 45 | return $top; 46 | }; 47 | $whatsTheRootOf = function(that) { 48 | if (that.jquery != null) { 49 | return that; 50 | } else if ($('#jasmine_content').length > 0) { 51 | return $('#jasmine_content'); 52 | } else { 53 | return $('
').appendTo('body'); 54 | } 55 | }; 56 | afterEach(function() { 57 | return $('#jasmine_content').remove(); 58 | }); 59 | isReady = false; 60 | rootId = "specContainer"; 61 | defaultConfiguration = { 62 | el: "div", 63 | cssClass: "", 64 | id: "", 65 | text: "", 66 | html: "", 67 | defaultAttribute: "class", 68 | attrs: {} 69 | }; 70 | defaults = $.extend({}, defaultConfiguration); 71 | $.jasmine = { 72 | inject: function(arg, context) { 73 | var $toInject, config, parent; 74 | if (isReady !== true) { 75 | init(); 76 | } 77 | parent = (context ? context : $("#" + rootId)); 78 | $toInject = void 0; 79 | if (itLooksLikeHtml(arg)) { 80 | $toInject = $(arg); 81 | } else { 82 | config = $.extend({}, defaults, arg, { 83 | userString: arg 84 | }); 85 | $toInject = $("<" + config.el + ">"); 86 | applyAttributes($toInject, config); 87 | injectContents($toInject, config); 88 | } 89 | return $toInject.appendTo(parent); 90 | }, 91 | configure: function(config) { 92 | return $.extend(defaults, config); 93 | }, 94 | restoreDefaults: function() { 95 | return defaults = $.extend({}, defaultConfiguration); 96 | }, 97 | noConflict: function() { 98 | root.jasmineFixture = originalJasmineFixture; 99 | root.inject = originalInject; 100 | root.affix = originalAffix; 101 | return this; 102 | } 103 | }; 104 | $.fn.inject = function(html) { 105 | return $.jasmine.inject(html, $(this)); 106 | }; 107 | applyAttributes = function($html, config) { 108 | var attrs, key, _results; 109 | attrs = $.extend({}, { 110 | id: config.id, 111 | "class": config["class"] || config.cssClass 112 | }, config.attrs); 113 | if (isString(config.userString)) { 114 | attrs[config.defaultAttribute] = config.userString; 115 | } 116 | _results = []; 117 | for (key in attrs) { 118 | if (attrs[key]) { 119 | _results.push($html.attr(key, attrs[key])); 120 | } else { 121 | _results.push(void 0); 122 | } 123 | } 124 | return _results; 125 | }; 126 | injectContents = function($el, config) { 127 | if (config.text && config.html) { 128 | throw "Error: because they conflict, you may only configure inject() to set `html` or `text`, not both! \n\nHTML was: " + config.html + " \n\n Text was: " + config.text; 129 | } else if (config.text) { 130 | return $el.text(config.text); 131 | } else { 132 | if (config.html) { 133 | return $el.html(config.html); 134 | } 135 | } 136 | }; 137 | itLooksLikeHtml = function(arg) { 138 | return isString(arg) && arg.indexOf("<") !== -1; 139 | }; 140 | isString = function(arg) { 141 | return arg && arg.constructor === String; 142 | }; 143 | init = function() { 144 | $("body").append("
"); 145 | return isReady = true; 146 | }; 147 | tidyUp = function() { 148 | $("#" + rootId).remove(); 149 | return isReady = false; 150 | }; 151 | $(function($) { 152 | return init(); 153 | }); 154 | afterEach(function() { 155 | return tidyUp(); 156 | }); 157 | return $.jasmine; 158 | }; 159 | if ($) { 160 | jasmineFixture = root.jasmineFixture($); 161 | return root.inject = root.inject || jasmineFixture.inject; 162 | } 163 | })(window.jQuery); 164 | 165 | createHTMLBlock = (function() { 166 | var bindData, bindEvents, parseAttributes, parseClasses, parseContents, parseEnclosure, parseReferences, parseVariableScope, regAttr, regAttrDfn, regAttrs, regCBrace, regClass, regClasses, regData, regDatas, regEvent, regEvents, regExclamation, regId, regReference, regTag, regTagNotContent, regZenTagDfn; 167 | createHTMLBlock = function($, ZenObject, data, functions, indexes) { 168 | var ZenCode, arr, block, blockAttrs, blockClasses, blockHTML, blockId, blockTag, blocks, el, el2, els, forScope, indexName, inner, len, obj, origZenCode, paren, result, ret, zc, zo; 169 | if ($.isPlainObject(ZenObject)) { 170 | ZenCode = ZenObject.main; 171 | } else { 172 | ZenCode = ZenObject; 173 | ZenObject = { 174 | main: ZenCode 175 | }; 176 | } 177 | origZenCode = ZenCode; 178 | if (indexes === undefined) { 179 | indexes = {}; 180 | } 181 | if (ZenCode.charAt(0) === "!" || $.isArray(data)) { 182 | if ($.isArray(data)) { 183 | forScope = ZenCode; 184 | } else { 185 | obj = parseEnclosure(ZenCode, "!"); 186 | obj = obj.substring(obj.indexOf(":") + 1, obj.length - 1); 187 | forScope = parseVariableScope(ZenCode); 188 | } 189 | while (forScope.charAt(0) === "@") { 190 | forScope = parseVariableScope("!for:!" + parseReferences(forScope, ZenObject)); 191 | } 192 | zo = ZenObject; 193 | zo.main = forScope; 194 | el = $(); 195 | if (ZenCode.substring(0, 5) === "!for:" || $.isArray(data)) { 196 | if (!$.isArray(data) && obj.indexOf(":") > 0) { 197 | indexName = obj.substring(0, obj.indexOf(":")); 198 | obj = obj.substr(obj.indexOf(":") + 1); 199 | } 200 | arr = ($.isArray(data) ? data : data[obj]); 201 | zc = zo.main; 202 | if ($.isArray(arr) || $.isPlainObject(arr)) { 203 | $.map(arr, function(value, index) { 204 | var next; 205 | zo.main = zc; 206 | if (indexName !== undefined) { 207 | indexes[indexName] = index; 208 | } 209 | if (!$.isPlainObject(value)) { 210 | value = { 211 | value: value 212 | }; 213 | } 214 | next = createHTMLBlock($, zo, value, functions, indexes); 215 | if (el.length !== 0) { 216 | return $.each(next, function(index, value) { 217 | return el.push(value); 218 | }); 219 | } 220 | }); 221 | } 222 | if (!$.isArray(data)) { 223 | ZenCode = ZenCode.substr(obj.length + 6 + forScope.length); 224 | } else { 225 | ZenCode = ""; 226 | } 227 | } else if (ZenCode.substring(0, 4) === "!if:") { 228 | result = parseContents("!" + obj + "!", data, indexes); 229 | if (result !== "undefined" || result !== "false" || result !== "") { 230 | el = createHTMLBlock($, zo, data, functions, indexes); 231 | } 232 | ZenCode = ZenCode.substr(obj.length + 5 + forScope.length); 233 | } 234 | ZenObject.main = ZenCode; 235 | } else if (ZenCode.charAt(0) === "(") { 236 | paren = parseEnclosure(ZenCode, "(", ")"); 237 | inner = paren.substring(1, paren.length - 1); 238 | ZenCode = ZenCode.substr(paren.length); 239 | zo = ZenObject; 240 | zo.main = inner; 241 | el = createHTMLBlock($, zo, data, functions, indexes); 242 | } else { 243 | blocks = ZenCode.match(regZenTagDfn); 244 | block = blocks[0]; 245 | if (block.length === 0) { 246 | return ""; 247 | } 248 | if (block.indexOf("@") >= 0) { 249 | ZenCode = parseReferences(ZenCode, ZenObject); 250 | zo = ZenObject; 251 | zo.main = ZenCode; 252 | return createHTMLBlock($, zo, data, functions, indexes); 253 | } 254 | block = parseContents(block, data, indexes); 255 | blockClasses = parseClasses($, block); 256 | if (regId.test(block)) { 257 | blockId = regId.exec(block)[1]; 258 | } 259 | blockAttrs = parseAttributes(block, data); 260 | blockTag = (block.charAt(0) === "{" ? "span" : "div"); 261 | if (ZenCode.charAt(0) !== "#" && ZenCode.charAt(0) !== "." && ZenCode.charAt(0) !== "{") { 262 | blockTag = regTag.exec(block)[1]; 263 | } 264 | if (block.search(regCBrace) !== -1) { 265 | blockHTML = block.match(regCBrace)[1]; 266 | } 267 | blockAttrs = $.extend(blockAttrs, { 268 | id: blockId, 269 | "class": blockClasses, 270 | html: blockHTML 271 | }); 272 | el = $("<" + blockTag + ">", blockAttrs); 273 | el.attr(blockAttrs); 274 | el = bindEvents(block, el, functions); 275 | el = bindData(block, el, data); 276 | ZenCode = ZenCode.substr(blocks[0].length); 277 | ZenObject.main = ZenCode; 278 | } 279 | if (ZenCode.length > 0) { 280 | if (ZenCode.charAt(0) === ">") { 281 | if (ZenCode.charAt(1) === "(") { 282 | zc = parseEnclosure(ZenCode.substr(1), "(", ")"); 283 | ZenCode = ZenCode.substr(zc.length + 1); 284 | } else if (ZenCode.charAt(1) === "!") { 285 | obj = parseEnclosure(ZenCode.substr(1), "!"); 286 | forScope = parseVariableScope(ZenCode.substr(1)); 287 | zc = obj + forScope; 288 | ZenCode = ZenCode.substr(zc.length + 1); 289 | } else { 290 | len = Math.max(ZenCode.indexOf("+"), ZenCode.length); 291 | zc = ZenCode.substring(1, len); 292 | ZenCode = ZenCode.substr(len); 293 | } 294 | zo = ZenObject; 295 | zo.main = zc; 296 | els = $(createHTMLBlock($, zo, data, functions, indexes)); 297 | els.appendTo(el); 298 | } 299 | if (ZenCode.charAt(0) === "+") { 300 | zo = ZenObject; 301 | zo.main = ZenCode.substr(1); 302 | el2 = createHTMLBlock($, zo, data, functions, indexes); 303 | $.each(el2, function(index, value) { 304 | return el.push(value); 305 | }); 306 | } 307 | } 308 | ret = el; 309 | return ret; 310 | }; 311 | bindData = function(ZenCode, el, data) { 312 | var datas, i, split; 313 | if (ZenCode.search(regDatas) === 0) { 314 | return el; 315 | } 316 | datas = ZenCode.match(regDatas); 317 | if (datas === null) { 318 | return el; 319 | } 320 | i = 0; 321 | while (i < datas.length) { 322 | split = regData.exec(datas[i]); 323 | if (split[3] === undefined) { 324 | $(el).data(split[1], data[split[1]]); 325 | } else { 326 | $(el).data(split[1], data[split[3]]); 327 | } 328 | i++; 329 | } 330 | return el; 331 | }; 332 | bindEvents = function(ZenCode, el, functions) { 333 | var bindings, fn, i, split; 334 | if (ZenCode.search(regEvents) === 0) { 335 | return el; 336 | } 337 | bindings = ZenCode.match(regEvents); 338 | if (bindings === null) { 339 | return el; 340 | } 341 | i = 0; 342 | while (i < bindings.length) { 343 | split = regEvent.exec(bindings[i]); 344 | if (split[2] === undefined) { 345 | fn = functions[split[1]]; 346 | } else { 347 | fn = functions[split[2]]; 348 | } 349 | $(el).bind(split[1], fn); 350 | i++; 351 | } 352 | return el; 353 | }; 354 | parseAttributes = function(ZenBlock, data) { 355 | var attrStrs, attrs, i, parts; 356 | if (ZenBlock.search(regAttrDfn) === -1) { 357 | return undefined; 358 | } 359 | attrStrs = ZenBlock.match(regAttrDfn); 360 | attrs = {}; 361 | i = 0; 362 | while (i < attrStrs.length) { 363 | parts = regAttr.exec(attrStrs[i]); 364 | attrs[parts[1]] = ""; 365 | if (parts[3] !== undefined) { 366 | attrs[parts[1]] = parseContents(parts[3], data); 367 | } 368 | i++; 369 | } 370 | return attrs; 371 | }; 372 | parseClasses = function($, ZenBlock) { 373 | var classes, clsString, i; 374 | ZenBlock = ZenBlock.match(regTagNotContent)[0]; 375 | if (ZenBlock.search(regClasses) === -1) { 376 | return undefined; 377 | } 378 | classes = ZenBlock.match(regClasses); 379 | clsString = ""; 380 | i = 0; 381 | while (i < classes.length) { 382 | clsString += " " + regClass.exec(classes[i])[1]; 383 | i++; 384 | } 385 | return $.trim(clsString); 386 | }; 387 | parseContents = function(ZenBlock, data, indexes) { 388 | var html; 389 | if (indexes === undefined) { 390 | indexes = {}; 391 | } 392 | html = ZenBlock; 393 | if (data === undefined) { 394 | return html; 395 | } 396 | while (regExclamation.test(html)) { 397 | html = html.replace(regExclamation, function(str, str2) { 398 | var begChar, fn, val; 399 | begChar = ""; 400 | if (str.indexOf("!for:") > 0 || str.indexOf("!if:") > 0) { 401 | return str; 402 | } 403 | if (str.charAt(0) !== "!") { 404 | begChar = str.charAt(0); 405 | str = str.substring(2, str.length - 1); 406 | } 407 | fn = new Function("data", "indexes", "var r=undefined;" + "with(data){try{r=" + str + ";}catch(e){}}" + "with(indexes){try{if(r===undefined)r=" + str + ";}catch(e){}}" + "return r;"); 408 | val = unescape(fn(data, indexes)); 409 | return begChar + val; 410 | }); 411 | } 412 | html = html.replace(/\\./g, function(str) { 413 | return str.charAt(1); 414 | }); 415 | return unescape(html); 416 | }; 417 | parseEnclosure = function(ZenCode, open, close, count) { 418 | var index, ret; 419 | if (close === undefined) { 420 | close = open; 421 | } 422 | index = 1; 423 | if (count === undefined) { 424 | count = (ZenCode.charAt(0) === open ? 1 : 0); 425 | } 426 | if (count === 0) { 427 | return; 428 | } 429 | while (count > 0 && index < ZenCode.length) { 430 | if (ZenCode.charAt(index) === close && ZenCode.charAt(index - 1) !== "\\") { 431 | count--; 432 | } else { 433 | if (ZenCode.charAt(index) === open && ZenCode.charAt(index - 1) !== "\\") { 434 | count++; 435 | } 436 | } 437 | index++; 438 | } 439 | ret = ZenCode.substring(0, index); 440 | return ret; 441 | }; 442 | parseReferences = function(ZenCode, ZenObject) { 443 | ZenCode = ZenCode.replace(regReference, function(str) { 444 | var fn; 445 | str = str.substr(1); 446 | fn = new Function("objs", "var r=\"\";" + "with(objs){try{" + "r=" + str + ";" + "}catch(e){}}" + "return r;"); 447 | return fn(ZenObject, parseReferences); 448 | }); 449 | return ZenCode; 450 | }; 451 | parseVariableScope = function(ZenCode) { 452 | var forCode, rest, tag; 453 | if (ZenCode.substring(0, 5) !== "!for:" && ZenCode.substring(0, 4) !== "!if:") { 454 | return undefined; 455 | } 456 | forCode = parseEnclosure(ZenCode, "!"); 457 | ZenCode = ZenCode.substr(forCode.length); 458 | if (ZenCode.charAt(0) === "(") { 459 | return parseEnclosure(ZenCode, "(", ")"); 460 | } 461 | tag = ZenCode.match(regZenTagDfn)[0]; 462 | ZenCode = ZenCode.substr(tag.length); 463 | if (ZenCode.length === 0 || ZenCode.charAt(0) === "+") { 464 | return tag; 465 | } else if (ZenCode.charAt(0) === ">") { 466 | rest = ""; 467 | rest = parseEnclosure(ZenCode.substr(1), "(", ")", 1); 468 | return tag + ">" + rest; 469 | } 470 | return undefined; 471 | }; 472 | regZenTagDfn = /([#\.\@]?[\w-]+|\[([\w-!?=:"']+(="([^"]|\\")+")? {0,})+\]|\~[\w$]+=[\w$]+|&[\w$]+(=[\w$]+)?|[#\.\@]?!([^!]|\\!)+!){0,}(\{([^\}]|\\\})+\})?/i; 473 | regTag = /(\w+)/i; 474 | regId = /#([\w-!]+)/i; 475 | regTagNotContent = /((([#\.]?[\w-]+)?(\[([\w!]+(="([^"]|\\")+")? {0,})+\])?)+)/i; 476 | regClasses = /(\.[\w-]+)/g; 477 | regClass = /\.([\w-]+)/i; 478 | regReference = /(@[\w$_][\w$_\d]+)/i; 479 | regAttrDfn = /(\[([\w-!]+(="?([^"]|\\")+"?)? {0,})+\])/ig; 480 | regAttrs = /([\w-!]+(="([^"]|\\")+")?)/g; 481 | regAttr = /([\w-!]+)(="?(([^"\]]|\\")+)"?)?/i; 482 | regCBrace = /\{(([^\}]|\\\})+)\}/i; 483 | regExclamation = /(?:([^\\]|^))!([^!]|\\!)+!/g; 484 | regEvents = /\~[\w$]+(=[\w$]+)?/g; 485 | regEvent = /\~([\w$]+)=([\w$]+)/i; 486 | regDatas = /&[\w$]+(=[\w$]+)?/g; 487 | regData = /&([\w$]+)(=([\w$]+))?/i; 488 | return createHTMLBlock; 489 | })(); 490 | 491 | }).call(this); 492 | -------------------------------------------------------------------------------- /spec/helpers/jasmine-given.js: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | jasmine-given 2.0.0 4 | Adds a Given-When-Then DSL to jasmine as an alternative style for specs 5 | site: https://github.com/searls/jasmine-given 6 | */ 7 | 8 | 9 | (function() { 10 | 11 | (function(jasmine) { 12 | var getBlock, mostRecentExpectations, mostRecentlyUsed, o, root, stringifyExpectation, whenList; 13 | mostRecentlyUsed = null; 14 | stringifyExpectation = function(expectation) { 15 | var matches; 16 | matches = expectation.toString().replace(/\n/g, '').match(/function\s?\(\)\s?{\s*(return\s+)?(.*?)(;)?\s*}/i); 17 | if (matches && matches.length >= 3) { 18 | return matches[2]; 19 | } else { 20 | return ""; 21 | } 22 | }; 23 | beforeEach(function() { 24 | return this.addMatchers({ 25 | toHaveReturnedFalseFromThen: function(context, n) { 26 | var exception, result; 27 | result = false; 28 | exception = void 0; 29 | try { 30 | result = this.actual.call(context); 31 | } catch (e) { 32 | exception = e; 33 | } 34 | this.message = function() { 35 | var msg; 36 | msg = "Then clause " + (n > 1 ? " #" + n : "") + " `" + (stringifyExpectation(this.actual)) + "` failed by "; 37 | if (exception) { 38 | msg += "throwing: " + exception.toString(); 39 | } else { 40 | msg += "returning false"; 41 | } 42 | return msg; 43 | }; 44 | return result === false; 45 | } 46 | }); 47 | }); 48 | root = this; 49 | root.Given = function() { 50 | mostRecentlyUsed = root.Given; 51 | return beforeEach(getBlock(arguments)); 52 | }; 53 | whenList = []; 54 | root.When = function() { 55 | var b; 56 | mostRecentlyUsed = root.When; 57 | b = getBlock(arguments); 58 | beforeEach(function() { 59 | return whenList.push(b); 60 | }); 61 | return afterEach(function() { 62 | return whenList.pop(); 63 | }); 64 | }; 65 | getBlock = function(thing) { 66 | var assignResultTo, setupFunction; 67 | setupFunction = o(thing).firstThat(function(arg) { 68 | return o(arg).isFunction(); 69 | }); 70 | assignResultTo = o(thing).firstThat(function(arg) { 71 | return o(arg).isString(); 72 | }); 73 | return function() { 74 | var context, result; 75 | context = jasmine.getEnv().currentSpec; 76 | result = setupFunction.call(context); 77 | if (assignResultTo) { 78 | if (!context[assignResultTo]) { 79 | return context[assignResultTo] = result; 80 | } else { 81 | throw new Error("Unfortunately, the variable '" + assignResultTo + "' is already assigned to: " + context[assignResultTo]); 82 | } 83 | } 84 | }; 85 | }; 86 | mostRecentExpectations = null; 87 | root.Then = function() { 88 | var expectationFunction, expectations, label; 89 | label = o(arguments).firstThat(function(arg) { 90 | return o(arg).isString(); 91 | }); 92 | expectationFunction = o(arguments).firstThat(function(arg) { 93 | return o(arg).isFunction(); 94 | }); 95 | mostRecentlyUsed = root.subsequentThen; 96 | mostRecentExpectations = expectations = [expectationFunction]; 97 | it("then " + (label != null ? label : stringifyExpectation(expectations)), function() { 98 | var block, i, _i, _len, _ref, _results; 99 | _ref = whenList != null ? whenList : []; 100 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 101 | block = _ref[_i]; 102 | block(); 103 | } 104 | i = 0; 105 | _results = []; 106 | while (i < expectations.length) { 107 | expect(expectations[i]).not.toHaveReturnedFalseFromThen(jasmine.getEnv().currentSpec, i + 1); 108 | _results.push(i++); 109 | } 110 | return _results; 111 | }); 112 | return { 113 | Then: subsequentThen, 114 | And: subsequentThen 115 | }; 116 | }; 117 | root.subsequentThen = function(additionalExpectation) { 118 | mostRecentExpectations.push(additionalExpectation); 119 | return this; 120 | }; 121 | mostRecentlyUsed = root.Given; 122 | root.And = function() { 123 | return mostRecentlyUsed.apply(this, jasmine.util.argsToArray(arguments)); 124 | }; 125 | return o = function(thing) { 126 | return { 127 | isFunction: function() { 128 | return Object.prototype.toString.call(thing) === "[object Function]"; 129 | }, 130 | isString: function() { 131 | return Object.prototype.toString.call(thing) === "[object String]"; 132 | }, 133 | firstThat: function(test) { 134 | var i; 135 | i = 0; 136 | while (i < thing.length) { 137 | if (test(thing[i]) === true) { 138 | return thing[i]; 139 | } 140 | i++; 141 | } 142 | return void 0; 143 | } 144 | }; 145 | }; 146 | })(jasmine); 147 | 148 | }).call(this); 149 | -------------------------------------------------------------------------------- /spec/helpers/jasmine-stealth.js: -------------------------------------------------------------------------------- 1 | // Generated by CoffeeScript 1.3.3 2 | 3 | /* 4 | jasmine-stealth 0.0.12 5 | Makes Jasmine spies a bit more robust 6 | site: https://github.com/searls/jasmine-stealth 7 | */ 8 | 9 | 10 | (function() { 11 | var Captor, fake, root, unfakes, whatToDoWhenTheSpyGetsCalled, _, 12 | __hasProp = {}.hasOwnProperty, 13 | __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; 14 | 15 | root = this; 16 | 17 | _ = function(obj) { 18 | return { 19 | each: function(iterator) { 20 | var item, _i, _len, _results; 21 | _results = []; 22 | for (_i = 0, _len = obj.length; _i < _len; _i++) { 23 | item = obj[_i]; 24 | _results.push(iterator(item)); 25 | } 26 | return _results; 27 | }, 28 | isFunction: function() { 29 | return Object.prototype.toString.call(obj) === "[object Function]"; 30 | }, 31 | isString: function() { 32 | return Object.prototype.toString.call(obj) === "[object String]"; 33 | } 34 | }; 35 | }; 36 | 37 | root.spyOnConstructor = function(owner, classToFake, methodsToSpy) { 38 | var fakeClass, spies; 39 | if (methodsToSpy == null) { 40 | methodsToSpy = []; 41 | } 42 | if (_(methodsToSpy).isString()) { 43 | methodsToSpy = [methodsToSpy]; 44 | } 45 | spies = { 46 | constructor: jasmine.createSpy("" + classToFake + "'s constructor") 47 | }; 48 | fakeClass = (function() { 49 | 50 | function _Class() { 51 | spies.constructor.apply(this, arguments); 52 | } 53 | 54 | return _Class; 55 | 56 | })(); 57 | _(methodsToSpy).each(function(methodName) { 58 | spies[methodName] = jasmine.createSpy("" + classToFake + "#" + methodName); 59 | return fakeClass.prototype[methodName] = function() { 60 | return spies[methodName].apply(this, arguments); 61 | }; 62 | }); 63 | fake(owner, classToFake, fakeClass); 64 | return spies; 65 | }; 66 | 67 | unfakes = []; 68 | 69 | afterEach(function() { 70 | _(unfakes).each(function(u) { 71 | return u(); 72 | }); 73 | return unfakes = []; 74 | }); 75 | 76 | fake = function(owner, thingToFake, newThing) { 77 | var originalThing; 78 | originalThing = owner[thingToFake]; 79 | owner[thingToFake] = newThing; 80 | return unfakes.push(function() { 81 | return owner[thingToFake] = originalThing; 82 | }); 83 | }; 84 | 85 | root.stubFor = root.spyOn; 86 | 87 | jasmine.createStub = jasmine.createSpy; 88 | 89 | jasmine.createStubObj = function(baseName, stubbings) { 90 | var name, obj, stubbing; 91 | if (stubbings.constructor === Array) { 92 | return jasmine.createSpyObj(baseName, stubbings); 93 | } else { 94 | obj = {}; 95 | for (name in stubbings) { 96 | stubbing = stubbings[name]; 97 | obj[name] = jasmine.createSpy(baseName + "." + name); 98 | if (_(stubbing).isFunction()) { 99 | obj[name].andCallFake(stubbing); 100 | } else { 101 | obj[name].andReturn(stubbing); 102 | } 103 | } 104 | return obj; 105 | } 106 | }; 107 | 108 | whatToDoWhenTheSpyGetsCalled = function(spy) { 109 | var matchesStub, priorStubbing; 110 | matchesStub = function(stubbing, args, context) { 111 | switch (stubbing.type) { 112 | case "args": 113 | return jasmine.getEnv().equals_(stubbing.ifThis, jasmine.util.argsToArray(args)); 114 | case "context": 115 | return jasmine.getEnv().equals_(stubbing.ifThis, context); 116 | } 117 | }; 118 | priorStubbing = spy.plan(); 119 | return spy.andCallFake(function() { 120 | var i, stubbing; 121 | i = 0; 122 | while (i < spy._stealth_stubbings.length) { 123 | stubbing = spy._stealth_stubbings[i]; 124 | if (matchesStub(stubbing, arguments, this)) { 125 | if (Object.prototype.toString.call(stubbing.thenThat) === "[object Function]") { 126 | return stubbing.thenThat(); 127 | } else { 128 | return stubbing.thenThat; 129 | } 130 | } 131 | i++; 132 | } 133 | return priorStubbing; 134 | }); 135 | }; 136 | 137 | jasmine.Spy.prototype.whenContext = function(context) { 138 | var addStubbing, spy; 139 | spy = this; 140 | spy._stealth_stubbings || (spy._stealth_stubbings = []); 141 | whatToDoWhenTheSpyGetsCalled(spy); 142 | addStubbing = function(thenThat) { 143 | spy._stealth_stubbings.push({ 144 | type: 'context', 145 | ifThis: context, 146 | thenThat: thenThat 147 | }); 148 | return spy; 149 | }; 150 | return { 151 | thenReturn: addStubbing, 152 | thenCallFake: addStubbing 153 | }; 154 | }; 155 | 156 | jasmine.Spy.prototype.when = function() { 157 | var addStubbing, ifThis, spy; 158 | spy = this; 159 | ifThis = jasmine.util.argsToArray(arguments); 160 | spy._stealth_stubbings || (spy._stealth_stubbings = []); 161 | whatToDoWhenTheSpyGetsCalled(spy); 162 | addStubbing = function(thenThat) { 163 | spy._stealth_stubbings.push({ 164 | type: 'args', 165 | ifThis: ifThis, 166 | thenThat: thenThat 167 | }); 168 | return spy; 169 | }; 170 | return { 171 | thenReturn: addStubbing, 172 | thenCallFake: addStubbing 173 | }; 174 | }; 175 | 176 | jasmine.Spy.prototype.mostRecentCallThat = function(callThat, context) { 177 | var i; 178 | i = this.calls.length - 1; 179 | while (i >= 0) { 180 | if (callThat.call(context || this, this.calls[i]) === true) { 181 | return this.calls[i]; 182 | } 183 | i--; 184 | } 185 | }; 186 | 187 | jasmine.Matchers.ArgThat = (function(_super) { 188 | 189 | __extends(ArgThat, _super); 190 | 191 | function ArgThat(matcher) { 192 | this.matcher = matcher; 193 | } 194 | 195 | ArgThat.prototype.jasmineMatches = function(actual) { 196 | return this.matcher(actual); 197 | }; 198 | 199 | return ArgThat; 200 | 201 | })(jasmine.Matchers.Any); 202 | 203 | jasmine.Matchers.ArgThat.prototype.matches = jasmine.Matchers.ArgThat.prototype.jasmineMatches; 204 | 205 | jasmine.argThat = function(expected) { 206 | return new jasmine.Matchers.ArgThat(expected); 207 | }; 208 | 209 | jasmine.Matchers.Capture = (function(_super) { 210 | 211 | __extends(Capture, _super); 212 | 213 | function Capture(captor) { 214 | this.captor = captor; 215 | } 216 | 217 | Capture.prototype.jasmineMatches = function(actual) { 218 | this.captor.value = actual; 219 | return true; 220 | }; 221 | 222 | return Capture; 223 | 224 | })(jasmine.Matchers.Any); 225 | 226 | jasmine.Matchers.Capture.prototype.matches = jasmine.Matchers.Capture.prototype.jasmineMatches; 227 | 228 | Captor = (function() { 229 | 230 | function Captor() {} 231 | 232 | Captor.prototype.capture = function() { 233 | return new jasmine.Matchers.Capture(this); 234 | }; 235 | 236 | return Captor; 237 | 238 | })(); 239 | 240 | jasmine.captor = function() { 241 | return new Captor(); 242 | }; 243 | 244 | }).call(this); 245 | -------------------------------------------------------------------------------- /spec/lib/cache_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'Cache', -> 2 | beforeEach -> 3 | @cache = new Cache 4 | 5 | describe 'fetch', -> 6 | it 'sets cache with constructor', -> 7 | expect(@cache.fetch('key', -> 'value')).toEqual('value') 8 | expect(@cache.get('key')).toEqual('value') 9 | 10 | it 'returns exsiting value', -> 11 | @cache.set 'key', 'previous' 12 | @cache.fetch('key', -> 'new value') 13 | expect(@cache.get('key')).toEqual('previous') 14 | 15 | describe 'clean', -> 16 | it 'removes least recently used keys', -> 17 | @cache.set 'foo', 'a' 18 | @cache.set 'bar', 'b' 19 | @cache.set 'baz', 'b' 20 | @cache.get 'bar' 21 | @cache.clean(1) 22 | expect(@cache.keys).toEqual(['bar']) 23 | -------------------------------------------------------------------------------- /spec/lib/paginated_collection_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'PaginatedCollection', -> 2 | beforeEach -> 3 | @collection = new PaginatedCollection() 4 | @request = $.Deferred() 5 | @xhr = 6 | getResponseHeader: jasmine.createSpy('getResponseHeader') 7 | 8 | spyOn($, 'ajax').andReturn(@request) 9 | 10 | describe 'paginate', -> 11 | context 'when there is a next link', -> 12 | beforeEach -> 13 | @xhr.getResponseHeader.andReturn('; rel="next"') 14 | 15 | it 'fetches next page', -> 16 | spyOn @collection, 'fetch' 17 | @collection.paginate({}, {}, @xhr) 18 | expect(@collection.fetch).toHaveBeenCalledWith({url:'/url?page=2', reset: false, remove:false}) 19 | 20 | context 'when there is not a next link', -> 21 | beforeEach -> 22 | @xhr.getResponseHeader.andReturn('; rel="prev"') 23 | spyOn @collection, 'fetch' 24 | 25 | it 'does not call fetch', -> 26 | @collection.paginate({}, {}, @xhr) 27 | expect(@collection.fetch).not.toHaveBeenCalled() 28 | 29 | it 'triggers "paginated" event', -> 30 | spy = jasmine.createSpy() 31 | @collection.on 'paginated', spy 32 | @collection.paginate({}, {}, @xhr) 33 | expect(spy).toHaveBeenCalled() 34 | -------------------------------------------------------------------------------- /spec/models/comment_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Models.Comment', -> 2 | describe 'isUnread', -> 3 | beforeEach -> 4 | @subject = new App.Models.Subject(last_read_at: moment('2013-12-25')) 5 | @collection = new App.Collections.Comments([], subject: @subject) 6 | 7 | it 'is true if no collection', -> 8 | comment = new App.Models.Comment() 9 | expect(comment.isUnread()).toBe(true) 10 | 11 | it 'is true if subject does not have last_read_at', -> 12 | @subject.last_read_at = null 13 | comment = new App.Models.Comment({}, collection: @collection) 14 | expect(comment.isUnread()).toBe(true) 15 | 16 | it 'is true created_at is later than last_read_at on subject', -> 17 | comment = new App.Models.Comment({created_at: moment('2013-12-26')}, collection: @collection) 18 | expect(comment.isUnread()).toBe(true) 19 | 20 | it 'is false if created_at is earlier than last_read_at on subject', -> 21 | comment = new App.Models.Comment({created_at: moment('2013-12-24')}, collection: @collection) 22 | expect(comment.isUnread()).toBe(false) 23 | -------------------------------------------------------------------------------- /spec/models/oauth_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Models.OAuth', -> 2 | beforeEach -> 3 | spyOn app, 'ajax' 4 | 5 | # Test double for window.location 6 | @location = 7 | assign: jasmine.createSpy('assign') 8 | search: '' 9 | pathname: '/foobar' 10 | 11 | App.Models.OAuth.prototype.location = @location 12 | 13 | @oauth = new App.Models.OAuth 14 | 15 | describe 'redirect', -> 16 | it 'changes window.location', -> 17 | @oauth.redirect(client_id: 123, scope: 'scope') 18 | expect(@location.assign).toHaveBeenCalledWith( 19 | "https://github.com/login/oauth/authorize?client_id=123&scope=scope" 20 | ) 21 | 22 | describe 'getCode', -> 23 | it 'returns undefined if href does not contain a code', -> 24 | expect(@oauth.getCode()).toBe(undefined) 25 | 26 | it 'returns code from window.location', -> 27 | @location.search = 'foo=bar&code=omg' 28 | expect(@oauth.getCode()).toEqual('omg') 29 | -------------------------------------------------------------------------------- /spec/models/repository_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Models.Repository', -> 2 | describe 'decrement', -> 3 | it 'decreases count', -> 4 | @repository = new App.Models.Repository(unread_count: 5) 5 | @repository.decrement() 6 | expect(@repository.get('unread_count')).toBe(4) 7 | 8 | it 'does not go below 0', -> 9 | @repository = new App.Models.Repository(unread_count: 0) 10 | @repository.decrement() 11 | expect(@repository.get('unread_count')).toBe(0) 12 | -------------------------------------------------------------------------------- /spec/models/subject_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Models.Subject', -> 2 | describe 'isUnread', -> 3 | it 'is true without last_read_at', -> 4 | model = new App.Models.Subject(last_read_at: null) 5 | expect(model.isUnread()).toBe(true) 6 | 7 | it 'is true created_at is later than last_read_at', -> 8 | model = new App.Models.Subject(created_at: moment('2013-12-26'), last_read_at: moment('2013-12-25')) 9 | expect(model.isUnread()).toBe(true) 10 | 11 | it 'is false if created_at is earlier than last_read_at', -> 12 | model = new App.Models.Subject(created_at: moment('2013-12-24'), last_read_at: moment('2013-12-25')) 13 | expect(model.isUnread()).toBe(false) 14 | -------------------------------------------------------------------------------- /spec/models/token_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Models.Token', -> 2 | beforeEach -> 3 | @localStorage = App.Models.Token.localStorage = {} 4 | 5 | describe 'get', -> 6 | it 'gets token from localStorage', -> 7 | @localStorage['token'] = 'from localStorage' 8 | expect(App.Models.Token.get()).toEqual('from localStorage') 9 | 10 | describe 'set', -> 11 | it 'sets token in localStorage', -> 12 | App.Models.Token.set('foo') 13 | expect(@localStorage['token']).toEqual('foo') 14 | -------------------------------------------------------------------------------- /spec/views/subject_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Views.Subject', -> 2 | beforeEach -> 3 | @view = new App.Models.Subject 4 | 5 | describe 'for', -> 6 | it 'returns PullRequest', -> 7 | model = new App.Models.Subject(type: 'PullRequest') 8 | expect(App.Views.Subject.for(model)).toBe(App.Views.Subject.PullRequest) 9 | 10 | it 'returns Issue', -> 11 | model = new App.Models.Subject(type: 'Issue') 12 | expect(App.Views.Subject.for(model)).toBe(App.Views.Subject.Issue) 13 | 14 | it 'returns Commit', -> 15 | model = new App.Models.Subject(type: 'Commit') 16 | expect(App.Views.Subject.for(model)).toBe(App.Views.Subject.Commit) 17 | 18 | it 'returns Subject for unknown type', -> 19 | model = new Backbone.Model() 20 | expect(App.Views.Subject.for(model)).toBe(App.Views.Subject.Unknown) 21 | -------------------------------------------------------------------------------- /spec/views/timeline/event_spec.coffee: -------------------------------------------------------------------------------- 1 | describe 'App.Views.TimelineEvent', -> 2 | beforeEach -> 3 | @notification = new Backbone.Model 4 | repository: {html_url: 'https://github.com/bkeepers/github-notifications'} 5 | @issue = new App.Models.Subject.Issue({}, {notification: @notification}) 6 | @pull = new App.Models.Subject.PullRequest( 7 | {head: {label: 'github:feature-branch'}, base: {label: 'github:master'}}, 8 | {notification: @notification} 9 | ) 10 | 11 | @event = new App.Models.Event(payload) 12 | 13 | @event.collection = {subject: @issue} 14 | @view = new App.Views.TimelineEvent(model: @event) 15 | 16 | text = ($el) -> 17 | $el.text().replace(/\s+/g, ' ') 18 | 19 | describe 'closed', -> 20 | it 'renders for an issue', -> 21 | @view.render() 22 | expect(text(@view.$el)).toMatch(/octocat closed the issue/) 23 | 24 | it 'renders for a pull request', -> 25 | @event.collection.subject = @pull 26 | @view.render() 27 | expect(text(@view.$el)).toMatch(/octocat closed the pull request/) 28 | 29 | describe 'reopened', -> 30 | beforeEach -> 31 | @event.set event: 'reopened' 32 | 33 | it 'renders for an issue', -> 34 | @view.render() 35 | expect(text(@view.$el)).toMatch(/octocat reopened the issue/) 36 | 37 | it 'renders for a pull request', -> 38 | @event.collection.subject = @pull 39 | @view.render() 40 | expect(text(@view.$el)).toMatch(/octocat reopened the pull request/) 41 | 42 | describe 'merged', -> 43 | beforeEach -> 44 | @event.collection.subject = @pull 45 | @event.set event: 'merged' 46 | 47 | it 'renders', -> 48 | @view.render() 49 | expect(text(@view.$el)).toMatch(/octocat merged commit 6dcb09b5 into github:master from github:feature-branch/) 50 | 51 | it 'links to commit', -> 52 | @view.render() 53 | expected = 'https://github.com/bkeepers/github-notifications/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e' 54 | expect(@view.$('.git-sha').attr('href')).toEqual(expected) 55 | 56 | describe 'referenced', -> 57 | beforeEach -> 58 | @event.set event: 'referenced' 59 | 60 | # TODO: item references (currently missing from GitHub API) 61 | 62 | describe 'a commit', -> 63 | # TODO: link to commit 64 | 65 | it 'renders', -> 66 | @view.render() 67 | expect(text(@view.$el)).toMatch(/referenced this issue from commit 6dcb09b5/) 68 | 69 | it 'links to commit', -> 70 | @view.render() 71 | expected = 'https://github.com/bkeepers/github-notifications/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e' 72 | expect(@view.$('.git-sha').attr('href')).toEqual(expected) 73 | 74 | payload = { 75 | "id": 1, 76 | "url": "https://api.github.com/repos/octocat/Hello-World/issues/events/1", 77 | "actor": { 78 | "login": "octocat", 79 | "id": 1, 80 | "avatar_url": "https://github.com/images/error/octocat_happy.gif", 81 | "gravatar_id": "somehexcode", 82 | "url": "https://api.github.com/users/octocat", 83 | "html_url": "https://github.com/octocat", 84 | "type": "User", 85 | }, 86 | "event": "closed", 87 | "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e", 88 | "created_at": "2011-04-14T16:00:49Z" 89 | } 90 | -------------------------------------------------------------------------------- /tasks/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/tasks/.gitkeep -------------------------------------------------------------------------------- /vendor/css/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/vendor/css/.gitkeep -------------------------------------------------------------------------------- /vendor/css/primer.css: -------------------------------------------------------------------------------- 1 | .flash-messages { 2 | margin-top: 15px; 3 | margin-bottom: 15px; } 4 | 5 | .flash, 6 | .flash-global { 7 | position: relative; 8 | border: 1px solid #97c1da; 9 | color: #264c72; 10 | background-color: #d0e3ef; 11 | background-image: -moz-linear-gradient(#d8ebf8, #d0e3ef); 12 | background-image: -webkit-linear-gradient(#d8ebf8, #d0e3ef); 13 | background-image: linear-gradient(#d8ebf8, #d0e3ef); 14 | background-repeat: repeat-x; 15 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); } 16 | .flash.flash-warn, 17 | .flash-global.flash-warn { 18 | color: #613A00; 19 | background-color: #f5dac0; 20 | background-image: -moz-linear-gradient(#ffe3c8, #f5dac0); 21 | background-image: -webkit-linear-gradient(#ffe3c8, #f5dac0); 22 | background-image: linear-gradient(#ffe3c8, #f5dac0); 23 | background-repeat: repeat-x; 24 | border-color: #dca874; } 25 | .flash.flash-error, 26 | .flash-global.flash-error { 27 | color: #911; 28 | background-color: #efd0d0; 29 | background-image: -moz-linear-gradient(#f8d8d8, #efd0d0); 30 | background-image: -webkit-linear-gradient(#f8d8d8, #efd0d0); 31 | background-image: linear-gradient(#f8d8d8, #efd0d0); 32 | background-repeat: repeat-x; 33 | border-color: #da9797; } 34 | .flash:hover, 35 | .flash-global:hover { 36 | border-color: #5f9fc6; } 37 | .flash.flash-warn:hover, 38 | .flash-global.flash-warn:hover { 39 | border-color: #cd8237; } 40 | .flash.flash-error:hover, 41 | .flash-global.flash-error:hover { 42 | border-color: #c65f5f; } 43 | .flash .flash-action, 44 | .flash-global .flash-action { 45 | float: right; 46 | margin-top: -4px; 47 | margin-left: 20px; } 48 | 49 | .flash { 50 | padding: 15px; 51 | border-radius: 3px; 52 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } 53 | .flash + .flash { 54 | margin-top: 5px; } 55 | .flash .close { 56 | float: right; 57 | cursor: pointer; 58 | opacity: 0.6; 59 | text-decoration: none; 60 | margin-top: 1px; } 61 | .flash:hover .close { 62 | opacity: 1; } 63 | 64 | .flash-global { 65 | padding: 10px; 66 | top: -1px; 67 | border-width: 1px 0; 68 | z-index: 5; } 69 | .flash-global h2, .flash-global p { 70 | font-size: 13px; 71 | margin-top: 0; 72 | margin-bottom: 0; 73 | line-height: 1.4; } 74 | .flash-global .flash-action { 75 | margin-top: 5px; } 76 | 77 | .css-truncate.css-truncate-target, .css-truncate .css-truncate-target { 78 | max-width: 125px; 79 | display: inline-block; 80 | overflow: hidden; 81 | text-overflow: ellipsis; 82 | vertical-align: top; 83 | white-space: nowrap; } 84 | .css-truncate.expandable.zeroclipboard-is-hover .css-truncate-target, .css-truncate.expandable.zeroclipboard-is-hover.css-truncate-target, .css-truncate.expandable:hover .css-truncate-target, .css-truncate.expandable:hover.css-truncate-target { 85 | max-width: 10000px !important; } 86 | 87 | .button, 88 | .minibutton { 89 | position: relative; 90 | display: inline-block; 91 | padding: 7px 12px; 92 | font-size: 13px; 93 | font-weight: bold; 94 | color: #333; 95 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.9); 96 | white-space: nowrap; 97 | background-color: #eeeeee; 98 | background-image: -moz-linear-gradient(#fcfcfc, #eeeeee); 99 | background-image: -webkit-linear-gradient(#fcfcfc, #eeeeee); 100 | background-image: linear-gradient(#fcfcfc, #eeeeee); 101 | background-repeat: repeat-x; 102 | border-radius: 3px; 103 | border: 1px solid #d5d5d5; 104 | vertical-align: middle; 105 | cursor: pointer; 106 | -webkit-touch-callout: none; 107 | -webkit-user-select: none; 108 | -khtml-user-select: none; 109 | -moz-user-select: none; 110 | -ms-user-select: none; 111 | user-select: none; 112 | -webkit-appearance: none; } 113 | .button:focus, 114 | .minibutton:focus { 115 | outline: none; 116 | text-decoration: none; 117 | border-color: #51a7e8; 118 | box-shadow: 0 0 5px rgba(81, 167, 232, 0.5); } 119 | .button:hover, .button:active, .button.zeroclipboard-is-hover, .button.zeroclipboard-is-active, 120 | .minibutton:hover, 121 | .minibutton:active, 122 | .minibutton.zeroclipboard-is-hover, 123 | .minibutton.zeroclipboard-is-active { 124 | text-decoration: none; 125 | background-color: #dddddd; 126 | background-image: -moz-linear-gradient(#eeeeee, #dddddd); 127 | background-image: -webkit-linear-gradient(#eeeeee, #dddddd); 128 | background-image: linear-gradient(#eeeeee, #dddddd); 129 | background-repeat: repeat-x; 130 | border-color: #ccc; } 131 | .button:active, .button.selected, .button.selected:hover, .button.zeroclipboard-is-active, 132 | .minibutton:active, 133 | .minibutton.selected, 134 | .minibutton.selected:hover, 135 | .minibutton.zeroclipboard-is-active { 136 | background-color: #dcdcdc; 137 | background-image: none; 138 | border-color: #b5b5b5; 139 | box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15); } 140 | .button:disabled, .button:disabled:hover, .button.disabled, .button.disabled:hover, 141 | .minibutton:disabled, 142 | .minibutton:disabled:hover, 143 | .minibutton.disabled, 144 | .minibutton.disabled:hover { 145 | opacity: .5; 146 | color: #666; 147 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.9); 148 | background-image: none; 149 | background-color: #e5e5e5; 150 | border-color: #c5c5c5; 151 | cursor: default; 152 | box-shadow: none; } 153 | .button.primary, 154 | .minibutton.primary { 155 | color: #fff; 156 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 157 | background-color: #60b044; 158 | background-image: -moz-linear-gradient(#8add6d, #60b044); 159 | background-image: -webkit-linear-gradient(#8add6d, #60b044); 160 | background-image: linear-gradient(#8add6d, #60b044); 161 | background-repeat: repeat-x; 162 | border-color: #5ca941; } 163 | .button.primary:hover, 164 | .minibutton.primary:hover { 165 | color: #fff; 166 | background-color: #569e3d; 167 | background-image: -moz-linear-gradient(#79d858, #569e3d); 168 | background-image: -webkit-linear-gradient(#79d858, #569e3d); 169 | background-image: linear-gradient(#79d858, #569e3d); 170 | background-repeat: repeat-x; 171 | border-color: #4a993e; } 172 | .button.primary:active, .button.primary.selected, 173 | .minibutton.primary:active, 174 | .minibutton.primary.selected { 175 | background-color: #569e3d; 176 | background-image: none; 177 | border-color: #418737; } 178 | .button.primary:disabled, .button.primary:disabled:hover, .button.primary.disabled, .button.primary.disabled:hover, 179 | .minibutton.primary:disabled, 180 | .minibutton.primary:disabled:hover, 181 | .minibutton.primary.disabled, 182 | .minibutton.primary.disabled:hover { 183 | color: #fff; 184 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 185 | background-color: #60b044; 186 | background-image: -moz-linear-gradient(#8add6d, #60b044); 187 | background-image: -webkit-linear-gradient(#8add6d, #60b044); 188 | background-image: linear-gradient(#8add6d, #60b044); 189 | background-repeat: repeat-x; 190 | border-color: #74bb5a #74bb5a #509338; } 191 | .button.danger, 192 | .minibutton.danger { 193 | color: #900; } 194 | .button.danger:hover, 195 | .minibutton.danger:hover { 196 | color: #fff; 197 | text-shadow: 0px -1px 0 rgba(0, 0, 0, 0.3); 198 | background-color: #b33630; 199 | background-image: -moz-linear-gradient(#dc5f59, #b33630); 200 | background-image: -webkit-linear-gradient(#dc5f59, #b33630); 201 | background-image: linear-gradient(#dc5f59, #b33630); 202 | background-repeat: repeat-x; 203 | border-color: #cd504a; } 204 | .button.danger:active, .button.danger.selected, 205 | .minibutton.danger:active, 206 | .minibutton.danger.selected { 207 | color: #fff; 208 | background-color: #b33630; 209 | background-image: none; 210 | border-color: #9f312c; } 211 | .button.danger:disabled, .button.danger:disabled:hover, .button.danger.disabled, .button.danger.disabled:hover, 212 | .minibutton.danger:disabled, 213 | .minibutton.danger:disabled:hover, 214 | .minibutton.danger.disabled, 215 | .minibutton.danger.disabled:hover { 216 | color: #900; 217 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.9); 218 | background-color: #e1e1e1; 219 | background-image: -moz-linear-gradient(white, #e1e1e1); 220 | background-image: -webkit-linear-gradient(white, #e1e1e1); 221 | background-image: linear-gradient(white, #e1e1e1); 222 | background-repeat: repeat-x; 223 | border-color: #c5c5c5; } 224 | .button.blue, .button.blue:hover, 225 | .minibutton.blue, 226 | .minibutton.blue:hover { 227 | color: #fff; 228 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 229 | background-color: #3072b3; 230 | background-image: -moz-linear-gradient(#599bcd, #3072b3); 231 | background-image: -webkit-linear-gradient(#599bcd, #3072b3); 232 | background-image: linear-gradient(#599bcd, #3072b3); 233 | background-repeat: repeat-x; 234 | border-color: #2a65a0; } 235 | .button.blue:hover, .button.blue:active, 236 | .minibutton.blue:hover, 237 | .minibutton.blue:active { 238 | border-color: #2a65a0; } 239 | .button.blue:active, .button.blue.selected, 240 | .minibutton.blue:active, 241 | .minibutton.blue.selected { 242 | background-color: #3072b3; 243 | background-image: none; 244 | border-color: #25588c; 245 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.15); } 246 | .button.blue:disabled, .button.blue.disabled, 247 | .minibutton.blue:disabled, 248 | .minibutton.blue.disabled { 249 | background-position: 0 0; } 250 | .button.dark-grey, .button.dark-grey:hover, 251 | .minibutton.dark-grey, 252 | .minibutton.dark-grey:hover { 253 | color: #fff; 254 | text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); 255 | background-color: #6d6d6d; 256 | background-image: -moz-linear-gradient(#8c8c8c, #6d6d6d); 257 | background-image: -webkit-linear-gradient(#8c8c8c, #6d6d6d); 258 | background-image: linear-gradient(#8c8c8c, #6d6d6d); 259 | background-repeat: repeat-x; 260 | border: 1px solid #707070; 261 | border-bottom-color: #595959; } 262 | .button.dark-grey:hover, .button.dark-grey:active, .button.dark-grey.selected, 263 | .minibutton.dark-grey:hover, 264 | .minibutton.dark-grey:active, 265 | .minibutton.dark-grey.selected { 266 | border-color: #585858; } 267 | .button.dark-grey:active, .button.dark-grey.selected, 268 | .minibutton.dark-grey:active, 269 | .minibutton.dark-grey.selected { 270 | background-color: #545454; 271 | background-image: none; 272 | border-color: #474747; 273 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.15); } 274 | .button.with-count, 275 | .minibutton.with-count { 276 | border-top-right-radius: 0; 277 | border-bottom-right-radius: 0; 278 | float: left; } 279 | 280 | .button img { 281 | position: relative; 282 | top: -1px; 283 | margin-right: 3px; 284 | vertical-align: middle; } 285 | 286 | .button > .octicon { 287 | vertical-align: middle; 288 | margin-top: -1px; } 289 | 290 | .minibutton { 291 | padding: 0 10px; 292 | line-height: 24px; 293 | box-shadow: none; } 294 | .minibutton:hover .octicon-device-desktop:before { 295 | background-position: -18px 0; } 296 | .minibutton i { 297 | font-weight: 500; 298 | font-style: normal; 299 | opacity: .6; } 300 | .minibutton code { 301 | line-height: 22px; } 302 | 303 | .button-block { 304 | display: block; 305 | width: 100%; 306 | text-align: center; 307 | -moz-box-sizing: border-box; 308 | box-sizing: border-box; } 309 | 310 | .button-link { 311 | display: inline; 312 | padding: 0; 313 | font-size: inherit; 314 | color: #4183c4; 315 | white-space: nowrap; 316 | background-color: transparent; 317 | border: 0; 318 | cursor: pointer; 319 | -webkit-touch-callout: none; 320 | -webkit-user-select: none; 321 | -khtml-user-select: none; 322 | -moz-user-select: none; 323 | -ms-user-select: none; 324 | user-select: none; 325 | -webkit-appearance: none; } 326 | .button-link:hover { 327 | text-decoration: underline; } 328 | 329 | input[type=text] + .minibutton { 330 | margin-left: 5px; } 331 | 332 | .minibutton .octicon { 333 | vertical-align: middle; 334 | margin-top: -1px; 335 | margin-right: 6px; 336 | -moz-transition: none; 337 | -webkit-transition: none; 338 | -o-transition: color 0 ease-in; 339 | transition: none; } 340 | .minibutton.zeroclipboard-button .octicon { 341 | margin-right: 0; } 342 | .minibutton.empty-icon .octicon { 343 | margin-right: 0; } 344 | .minibutton .octicon-arrow-right { 345 | float: right; 346 | margin-right: 0; 347 | margin-left: 5px; 348 | margin-top: 4px; } 349 | 350 | .hidden-text-expander { 351 | display: block; } 352 | .hidden-text-expander.inline { 353 | display: inline-block; 354 | line-height: 0; 355 | margin-left: 5px; 356 | position: relative; 357 | top: -1px; } 358 | .hidden-text-expander a { 359 | background: #ddd; 360 | color: #555; 361 | padding: 0 5px; 362 | line-height: 6px; 363 | height: 12px; 364 | font-size: 12px; 365 | font-weight: bold; 366 | vertical-align: middle; 367 | display: inline-block; 368 | border-radius: 1px; 369 | text-decoration: none; } 370 | .hidden-text-expander a:hover { 371 | background-color: #ccc; 372 | text-decoration: none; } 373 | .hidden-text-expander a:active { 374 | background-color: #4183C4; 375 | color: #fff; } 376 | 377 | .social-count { 378 | padding: 0 7px 0; 379 | font-size: 11px; 380 | font-weight: bold; 381 | float: left; 382 | background-color: #fff; 383 | line-height: 24px; 384 | color: #333333; 385 | vertical-align: middle; 386 | border: 1px solid #ddd; 387 | border-left: 0; 388 | border-top-right-radius: 3px; 389 | border-bottom-right-radius: 3px; } 390 | .social-count:hover { 391 | color: #4183c4; 392 | cursor: pointer; 393 | text-decoration: none; } 394 | 395 | .button-group { 396 | display: inline-block; 397 | vertical-align: middle; } 398 | .button-group:before, .button-group:after { 399 | content: " "; 400 | display: table; } 401 | .button-group:after { 402 | clear: both; } 403 | .button-group .button, 404 | .button-group .minibutton { 405 | position: relative; 406 | float: left; 407 | border-radius: 0; } 408 | .button-group .button:first-child, 409 | .button-group .minibutton:first-child { 410 | border-top-left-radius: 3px; 411 | border-bottom-left-radius: 3px; } 412 | .button-group .button:last-child, 413 | .button-group .minibutton:last-child { 414 | border-top-right-radius: 3px; 415 | border-bottom-right-radius: 3px; } 416 | .button-group .button:hover, .button-group .button:focus, .button-group .button:active, .button-group .button.selected, 417 | .button-group .minibutton:hover, 418 | .button-group .minibutton:focus, 419 | .button-group .minibutton:active, 420 | .button-group .minibutton.selected { 421 | z-index: 2; } 422 | .button-group .button + .button, 423 | .button-group .minibutton + .minibutton { 424 | margin-left: -1px; 425 | box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.2); } 426 | .button-group .button + .button:hover, 427 | .button-group .minibutton + .minibutton:hover { 428 | box-shadow: none; } 429 | .button-group .button + .button:active, .button-group .button + .button.selected, 430 | .button-group .minibutton + .minibutton:active, 431 | .button-group .minibutton + .minibutton.selected { 432 | box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.15); } 433 | 434 | .button-group + .button-group, 435 | .button-group + .button, 436 | .button-group + .minibutton { 437 | margin-left: 5px; } 438 | 439 | .markdown-body { 440 | font-size: 15px; 441 | line-height: 1.7; 442 | overflow: hidden; 443 | word-wrap: break-word; } 444 | .markdown-body > *:first-child { 445 | margin-top: 0 !important; } 446 | .markdown-body > *:last-child { 447 | margin-bottom: 0 !important; } 448 | .markdown-body a.absent { 449 | color: #c00; } 450 | .markdown-body a.anchor { 451 | display: block; 452 | padding-right: 6px; 453 | padding-left: 30px; 454 | margin-left: -30px; 455 | cursor: pointer; 456 | position: absolute; 457 | top: 0; 458 | left: 0; 459 | bottom: 0; } 460 | .markdown-body a.anchor:focus { 461 | outline: none; } 462 | .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6 { 463 | margin: 1em 0 15px; 464 | padding: 0; 465 | font-weight: bold; 466 | line-height: 1.7; 467 | cursor: text; 468 | position: relative; } 469 | .markdown-body h1 .octicon-link, .markdown-body h2 .octicon-link, .markdown-body h3 .octicon-link, .markdown-body h4 .octicon-link, .markdown-body h5 .octicon-link, .markdown-body h6 .octicon-link { 470 | display: none; 471 | color: #000; } 472 | .markdown-body h1:hover a.anchor, .markdown-body h2:hover a.anchor, .markdown-body h3:hover a.anchor, .markdown-body h4:hover a.anchor, .markdown-body h5:hover a.anchor, .markdown-body h6:hover a.anchor { 473 | text-decoration: none; 474 | line-height: 1; 475 | padding-left: 8px; 476 | margin-left: -30px; 477 | top: 15%; } 478 | .markdown-body h1:hover a.anchor .octicon-link, .markdown-body h2:hover a.anchor .octicon-link, .markdown-body h3:hover a.anchor .octicon-link, .markdown-body h4:hover a.anchor .octicon-link, .markdown-body h5:hover a.anchor .octicon-link, .markdown-body h6:hover a.anchor .octicon-link { 479 | display: inline-block; } 480 | .markdown-body h1 tt, .markdown-body h1 code, .markdown-body h2 tt, .markdown-body h2 code, .markdown-body h3 tt, .markdown-body h3 code, .markdown-body h4 tt, .markdown-body h4 code, .markdown-body h5 tt, .markdown-body h5 code, .markdown-body h6 tt, .markdown-body h6 code { 481 | font-size: inherit; } 482 | .markdown-body h1 { 483 | font-size: 2.5em; 484 | border-bottom: 1px solid #ddd; } 485 | .markdown-body h2 { 486 | font-size: 2em; 487 | border-bottom: 1px solid #eee; } 488 | .markdown-body h3 { 489 | font-size: 1.5em; } 490 | .markdown-body h4 { 491 | font-size: 1.2em; } 492 | .markdown-body h5 { 493 | font-size: 1em; } 494 | .markdown-body h6 { 495 | color: #777; 496 | font-size: 1em; } 497 | .markdown-body p, 498 | .markdown-body blockquote, 499 | .markdown-body ul, .markdown-body ol, .markdown-body dl, 500 | .markdown-body table, 501 | .markdown-body pre { 502 | margin: 15px 0; } 503 | .markdown-body hr { 504 | background: transparent image-url("primer/markdown/dirty-shade.png") repeat-x 0 0; 505 | border: 0 none; 506 | color: #ccc; 507 | height: 4px; 508 | padding: 0; 509 | margin: 15px 0; } 510 | .markdown-body ul, .markdown-body ol { 511 | padding-left: 30px; } 512 | .markdown-body ul.no-list, .markdown-body ol.no-list { 513 | list-style-type: none; 514 | padding: 0; } 515 | .markdown-body ul ul, 516 | .markdown-body ul ol, 517 | .markdown-body ol ol, 518 | .markdown-body ol ul { 519 | margin-top: 0; 520 | margin-bottom: 0; } 521 | .markdown-body dl { 522 | padding: 0; } 523 | .markdown-body dl dt { 524 | font-size: 14px; 525 | font-weight: bold; 526 | font-style: italic; 527 | padding: 0; 528 | margin-top: 15px; } 529 | .markdown-body dl dd { 530 | margin-bottom: 15px; 531 | padding: 0 15px; } 532 | .markdown-body blockquote { 533 | border-left: 4px solid #DDD; 534 | padding: 0 15px; 535 | color: #777; } 536 | .markdown-body blockquote > :first-child { 537 | margin-top: 0px; } 538 | .markdown-body blockquote > :last-child { 539 | margin-bottom: 0px; } 540 | .markdown-body table { 541 | width: 100%; 542 | overflow: auto; 543 | display: block; } 544 | .markdown-body table th { 545 | font-weight: bold; } 546 | .markdown-body table th, .markdown-body table td { 547 | border: 1px solid #ddd; 548 | padding: 6px 13px; } 549 | .markdown-body table tr { 550 | border-top: 1px solid #ccc; 551 | background-color: #fff; } 552 | .markdown-body table tr:nth-child(2n) { 553 | background-color: #f8f8f8; } 554 | .markdown-body img { 555 | max-width: 100%; 556 | -moz-box-sizing: border-box; 557 | box-sizing: border-box; } 558 | .markdown-body span.frame { 559 | display: block; 560 | overflow: hidden; } 561 | .markdown-body span.frame > span { 562 | border: 1px solid #ddd; 563 | display: block; 564 | float: left; 565 | overflow: hidden; 566 | margin: 13px 0 0; 567 | padding: 7px; 568 | width: auto; } 569 | .markdown-body span.frame span img { 570 | display: block; 571 | float: left; } 572 | .markdown-body span.frame span span { 573 | clear: both; 574 | color: #333; 575 | display: block; 576 | padding: 5px 0 0; } 577 | .markdown-body span.align-center { 578 | display: block; 579 | overflow: hidden; 580 | clear: both; } 581 | .markdown-body span.align-center > span { 582 | display: block; 583 | overflow: hidden; 584 | margin: 13px auto 0; 585 | text-align: center; } 586 | .markdown-body span.align-center span img { 587 | margin: 0 auto; 588 | text-align: center; } 589 | .markdown-body span.align-right { 590 | display: block; 591 | overflow: hidden; 592 | clear: both; } 593 | .markdown-body span.align-right > span { 594 | display: block; 595 | overflow: hidden; 596 | margin: 13px 0 0; 597 | text-align: right; } 598 | .markdown-body span.align-right span img { 599 | margin: 0; 600 | text-align: right; } 601 | .markdown-body span.float-left { 602 | display: block; 603 | margin-right: 13px; 604 | overflow: hidden; 605 | float: left; } 606 | .markdown-body span.float-left span { 607 | margin: 13px 0 0; } 608 | .markdown-body span.float-right { 609 | display: block; 610 | margin-left: 13px; 611 | overflow: hidden; 612 | float: right; } 613 | .markdown-body span.float-right > span { 614 | display: block; 615 | overflow: hidden; 616 | margin: 13px auto 0; 617 | text-align: right; } 618 | .markdown-body code, .markdown-body tt { 619 | margin: 0 2px; 620 | padding: 0px 5px; 621 | border: 1px solid #ddd; 622 | background-color: #f8f8f8; 623 | border-radius: 3px; } 624 | .markdown-body code { 625 | white-space: nowrap; } 626 | .markdown-body pre > code { 627 | margin: 0; 628 | padding: 0; 629 | white-space: pre; 630 | border: none; 631 | background: transparent; } 632 | .markdown-body .highlight pre, .markdown-body pre { 633 | background-color: #f8f8f8; 634 | border: 1px solid #ddd; 635 | font-size: 13px; 636 | line-height: 19px; 637 | overflow: auto; 638 | padding: 6px 10px; 639 | border-radius: 3px; } 640 | .markdown-body pre { 641 | word-wrap: normal; } 642 | .markdown-body pre code, .markdown-body pre tt { 643 | margin: 0; 644 | padding: 0; 645 | background-color: transparent; 646 | border: none; 647 | word-wrap: normal; } 648 | 649 | .menu-container { 650 | float: left; 651 | width: 200px; 652 | padding: 3px; 653 | background: #efefef; 654 | border-radius: 2px; } 655 | 656 | .menu { 657 | background: #fafafb; 658 | border-radius: 2px; 659 | border: 1px solid #d8d8d8; 660 | list-style: none; } 661 | .menu a:hover { 662 | text-decoration: none; } 663 | .menu li { 664 | border-bottom: 1px solid #eee; 665 | border-top: 1px solid #fff; } 666 | .menu li:last-child { 667 | border-bottom: none; } 668 | .menu li:first-child { 669 | border-top: none; } 670 | .menu a { 671 | display: block; 672 | padding: 8px 10px 8px 8px; 673 | text-shadow: 0 1px 0 #fff; 674 | border-left: 2px solid #fafafb; } 675 | .menu a:hover { 676 | background: #fdfdfe; } 677 | .menu a .octicon { 678 | color: #333333; 679 | width: 16px; 680 | text-align: center; } 681 | .menu a.selected { 682 | background: #fff; 683 | border-left: 2px solid #d26911; 684 | font-weight: bold; 685 | color: #222; 686 | cursor: default; 687 | box-shadow: inset 0 0px 1px rgba(0, 0, 0, 0.1); } 688 | .menu a .counter { 689 | float: right; 690 | margin: 0 0 0 5px; 691 | padding: 2px 5px; 692 | font-size: 11px; 693 | font-weight: bold; 694 | color: #999; 695 | background: #eee; 696 | border-radius: 2px; } 697 | .menu .menu-warning { 698 | color: #D26911; 699 | float: right; } 700 | 701 | .accordion { 702 | background: #fafafb; 703 | list-style: none; } 704 | .accordion .section { 705 | border-top: 1px solid #d8d8d8; 706 | border-top: none; } 707 | .accordion .section:first-child { 708 | border-top: none; } 709 | .accordion .section a.section-head { 710 | background-color: #e0e0e0; 711 | background-image: -moz-linear-gradient(#fafafa, #e0e0e0); 712 | background-image: -webkit-linear-gradient(#fafafa, #e0e0e0); 713 | background-image: linear-gradient(#fafafa, #e0e0e0); 714 | background-repeat: repeat-x; 715 | box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); 716 | display: block; 717 | padding: 10px 10px; 718 | border-bottom: 1px solid #ccc; 719 | color: #222; 720 | font-weight: bold; 721 | font-size: 14px; 722 | line-height: 20px; 723 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); 724 | border-left: 0 none; } 725 | .accordion .section a.section-head img { 726 | float: left; 727 | margin: 0 10px 0 0; 728 | border-radius: 2px; } 729 | .accordion .section .section-nav { 730 | list-style: none; 731 | display: none; } 732 | .accordion .section .section-nav.expanded { 733 | display: block; } 734 | 735 | .highlight { 736 | background: #ffffff; } 737 | .highlight .c { 738 | color: #999988; 739 | font-style: italic; } 740 | .highlight .err { 741 | color: #a61717; 742 | background-color: #e3d2d2; } 743 | .highlight .k { 744 | font-weight: bold; } 745 | .highlight .o { 746 | font-weight: bold; } 747 | .highlight .cm { 748 | color: #999988; 749 | font-style: italic; } 750 | .highlight .cp { 751 | color: #999999; 752 | font-weight: bold; } 753 | .highlight .c1 { 754 | color: #999988; 755 | font-style: italic; } 756 | .highlight .cs { 757 | color: #999999; 758 | font-weight: bold; 759 | font-style: italic; } 760 | .highlight .gd { 761 | color: #000000; 762 | background-color: #ffdddd; } 763 | .highlight .gd .x { 764 | color: #000000; 765 | background-color: #ffaaaa; } 766 | .highlight .ge { 767 | font-style: italic; } 768 | .highlight .gr { 769 | color: #aa0000; } 770 | .highlight .gh { 771 | color: #999999; } 772 | .highlight .gi { 773 | color: #000000; 774 | background-color: #ddffdd; } 775 | .highlight .gi .x { 776 | color: #000000; 777 | background-color: #aaffaa; } 778 | .highlight .go { 779 | color: #888888; } 780 | .highlight .gp { 781 | color: #555555; } 782 | .highlight .gs { 783 | font-weight: bold; } 784 | .highlight .gu { 785 | color: #800080; 786 | font-weight: bold; } 787 | .highlight .gt { 788 | color: #aa0000; } 789 | .highlight .kc { 790 | font-weight: bold; } 791 | .highlight .kd { 792 | font-weight: bold; } 793 | .highlight .kn { 794 | font-weight: bold; } 795 | .highlight .kp { 796 | font-weight: bold; } 797 | .highlight .kr { 798 | font-weight: bold; } 799 | .highlight .kt { 800 | color: #445588; 801 | font-weight: bold; } 802 | .highlight .m { 803 | color: #009999; } 804 | .highlight .s { 805 | color: #dd1144; } 806 | .highlight .n { 807 | color: #333333; } 808 | .highlight .na { 809 | color: teal; } 810 | .highlight .nb { 811 | color: #0086b3; } 812 | .highlight .nc { 813 | color: #445588; 814 | font-weight: bold; } 815 | .highlight .no { 816 | color: teal; } 817 | .highlight .ni { 818 | color: purple; } 819 | .highlight .ne { 820 | color: #990000; 821 | font-weight: bold; } 822 | .highlight .nf { 823 | color: #990000; 824 | font-weight: bold; } 825 | .highlight .nn { 826 | color: #555555; } 827 | .highlight .nt { 828 | color: navy; } 829 | .highlight .nv { 830 | color: teal; } 831 | .highlight .ow { 832 | font-weight: bold; } 833 | .highlight .w { 834 | color: #bbbbbb; } 835 | .highlight .mf { 836 | color: #009999; } 837 | .highlight .mh { 838 | color: #009999; } 839 | .highlight .mi { 840 | color: #009999; } 841 | .highlight .mo { 842 | color: #009999; } 843 | .highlight .sb { 844 | color: #dd1144; } 845 | .highlight .sc { 846 | color: #dd1144; } 847 | .highlight .sd { 848 | color: #dd1144; } 849 | .highlight .s2 { 850 | color: #dd1144; } 851 | .highlight .se { 852 | color: #dd1144; } 853 | .highlight .sh { 854 | color: #dd1144; } 855 | .highlight .si { 856 | color: #dd1144; } 857 | .highlight .sx { 858 | color: #dd1144; } 859 | .highlight .sr { 860 | color: #009926; } 861 | .highlight .s1 { 862 | color: #dd1144; } 863 | .highlight .ss { 864 | color: #990073; } 865 | .highlight .bp { 866 | color: #999999; } 867 | .highlight .vc { 868 | color: teal; } 869 | .highlight .vg { 870 | color: teal; } 871 | .highlight .vi { 872 | color: teal; } 873 | .highlight .il { 874 | color: #009999; } 875 | .highlight .gc { 876 | color: #999; 877 | background-color: #EAF2F5; } 878 | 879 | .type-csharp .highlight .k { 880 | color: blue; } 881 | .type-csharp .highlight .kt { 882 | color: blue; } 883 | .type-csharp .highlight .nf { 884 | color: #000000; 885 | font-weight: normal; } 886 | .type-csharp .highlight .nc { 887 | color: #2b91af; } 888 | .type-csharp .highlight .nn { 889 | color: black; } 890 | .type-csharp .highlight .s { 891 | color: #a31515; } 892 | .type-csharp .highlight .sc { 893 | color: #a31515; } 894 | -------------------------------------------------------------------------------- /vendor/img/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkeepers/github-notifications/6055b9ff198214802f7500a4862bcbf4ac86b59a/vendor/img/.gitkeep --------------------------------------------------------------------------------