Building a feed reader with AngularJS
Please Note: This post was recently updated here and while this post can explain the problem specification and my thought processes, it is full of anti-patterns and should not used as reference. The revised post explaining the anti-patterns should be used instead.
One of my friends had an idea for a real-time RSS feed reader and he asked me to implement it. The specifications were this: it should
- accept an RSS feed (will cover)
- display items from that RSS feed in real time (will cover, partially)
- allow the user to set items to a "read" state (will cover)
- remember the user's "read" items
- have a mark and clear read items button (will cover)
- be able to toggle a light and dark theme (will cover)
- have a user login system
Accept an RSS feed
AngularJS basically allows you to bind data values to HTML elements. If I had the following HTML:<html ng-app> ... <div ng-controller="RssFeedCtrl"> <div class='container-fluid margin-top'> <div class='span12'> <form class="form-horizontal"> <div class="control-group"> <input id='rssFeed' ng-model='rssFeed' type="text" placeholder="RSS Feed"> <button id='update' type="submit" class="btn" ng-click="updateModel()">Load feed</button> </div> </form> </div> </div> </div> ... </html>I would know that the input text-box #rssFeed will always be linked up to the variable located in $scope.rssFeed, as the ng-model attribute is set to rssFeed. So I can use this code:
function RssFeedCtrl($scope) { $scope.articles = [ ]; $scope.rssFeed = 'http://hnapp.com/api/items/rss/a817dd49f3fe75b6fc2764bd98b714f7'; }to automatically populate #rssFeed. If I were to change the value in that text box, then I could see that altered value in my javascript in $scope.rssFeed. From the HTML, you'll also notice there's a button with an ng-click="updateModel()" attribute. So whenever the button is clicked, $scope.updateModel is called.
Display items from that RSS feed in real time
This code can then be used to populate $scope.articles://converts from RSS object to an object we're interested in var parseEntry = function(el) { var date = el.publishedDate || el.pubDate; var content = el.content || el.description; return { title: el.title, content: content, read: false, date: date, link: el.link, shortLink: hostname(el.link) }; } //parses an RSS feed using Google API var parseRSS = function(url, callback) { $.ajax({ url: '//ajax.googleapis.com/ajax/services/feed/load?v=1.0&num=50&callback=?&q=' + encodeURIComponent(url), dataType: 'json', success: callback }); } //called from button click $scope.updateModel = function() { parseRSS($scope.rssFeed, function(data) { if(data == null) return; var mostRecentDate = null; if($scope.articles.length && $scope.rssFeed == $scope.originalRssFeed) mostRecentDate = $scope.articles[0].date; var entries = _.map(data.responseData.feed.entries, function(el) { return parseEntry(el); }); if(mostRecentDate != null) { entries = _.filter(entries, function(el) { return el.date < mostRecentDate; }); } if($scope.rssFeed != $scope.originalRssFeed) { $scope.articles = entries; $scope.originalRssFeed = $scope.rssFeed; } else $scope.articles = _.union($scope.articles, entries); $scope.$apply(function() { $scope.articles = _.sortBy($scope.articles, function(el) { return el.date; }); }); }); };This code basically calls another function, parseRSS, that calls a Google API to parse an XML feed. You'll notice that the URL that is passed in through accessing that ng-model we declared above, rssFeed. Using the data, we'll do some manipulation to convert the RSS feed item into an item we care about, then basically just add it to an array of all of the feed items: $scope.articles. [The code is a little longer to determine if the rss feed has been changed since the last time updateModel was called - that's why there are mostRecentDate and originalRssFeed variables. There's also a _filter to remove redundant RSS items (_.union wasn't working due to a unique hash key Angular assigns its objects)] Finally, you might notice the last few lines:
$scope.$apply(function() { $scope.articles = _.sortBy($scope.articles, function(el) { return el.date; }); });It would seem that just the $scope.articles assignment would be enough, but for some reason, this doesn't register with AngularJS on the very first load (it does populate on subsequent loads). If it is wrapped in $scope.$apply, then it seems to ensure AngularJS manually registers or listens to the event. Now, to keep updating, I'm going to basically just keep clicking that updateButton so updateModel() keeps getting called.
$(function() { var $update = $('#update'); $update.click(); //then call every 30 secs var timeout = 30000; setInterval(function() { if(document.activeElement.nodeName != 'INPUT' && document.activeElement.id != 'rssFeed') $update.click(); }, timeout); });So it will look for new data every 30 seconds and will only fire if the $update text field isn't focused (so it won't fire if the user is in the middle of changing the RSS feed) - I couldn't get Jquery .is(":focus") working, so I used a different work around.
Allow the user to set items to a "read" state
Now that we have $scope.articles set up, let's write the HTML to display it.<div ng-show='existingArticles()' class='container-fluid margin-bottom'> <div class='span12 margin-bottom'> <button class="btn" ng-click="markAll()">Mark all as <span ng-show='allAreRead()'>un</span>read</button> <button class="btn" ng-click="clearCompleted()">Clear Read</button> <button class="btn" ng-click="showOrHideAll()"><span>Toggle Headlines/Content</span></button> <button class="btn" id='toggleTheme'><span>Toggle Dark/Light Theme</span></button> </div> <div ng-hide='article.cleared' class='span12 read-' ng-repeat="article in articles"> <span ng-click='toggleShow(article)' class='title'></span> <span><a href='' target='_blank'></a></span> <span ng-click="markAsRead(article)" class='markRead'>(Mark as <span ng-show='article.read'>un</span>read)</span> <div ng-show='article.show' class='content'></div> </div> </div> <div ng-hide='existingArticles()' class='container-fluid'> <div ngclass='span12'> You may have read all of the articles... :/ I'll try to load some more... </div> </div>If there are existingArticles (that's a function defined in scope that checks that there exist some non-cleared articles), then the first div.container-fluid with the buttons and articles is shown. If not, then the second div.container-fluid is shown indicating there are no articles currently. When there are articles, then each article is looped via the ng-repeat, each in its own div.span12. It'll show the title, link, shortlink, and content, and a button to mark the item as "read." One note is that content is shown when the title is clicked - via the ng-click='toggleShow(article)' - this shows that you can pass in the article item as a parameter:
$scope.toggleShow = function(article) { article.show = !article.show; };
Have a mark and clear read items button
To mark and clear read items, we just set up buttons and set their ng-click handlers. $scope has a markAll() to mark all read/unread and a clearCompleted(). markAll() makes use of an allAreRead() function which uses Underscore to determine if all of the articles have been marked read. You can see them in the finished Gist, but it's mostly straightforward. markAll() just needs to mark all items as either read=true or read=false. clearCompleted() just needs to set the cleared attribute to true for the read items, and ng-hide will do the rest.Be able to toggle a light and dark theme
To do this, there's a #toggleTheme button. Instead of using AngularJS, I'll just use JQuery to toggle a CSS class on the body tag:$(function() { var $body = $('body'); $('#toggleTheme').click(function() { $body.toggleClass('dark'); }); });You can view the finished Gist here: http://gist.github.com/4441416 and some working code here: http://dchang.me/feed-reader/v1