/** This view is for rendering an icon representing the status of a topic @class TopicView @extends Discourse.View @namespace Discourse @uses Discourse.Scrolling @module Discourse **/ Discourse.TopicView = Discourse.View.extend(Discourse.Scrolling, { templateName: 'topic', topicBinding: 'controller.model', userFiltersBinding: 'controller.userFilters', classNameBindings: ['controller.multiSelect:multi-select', 'topic.archetype', 'topic.category.secure:secure_category', 'topic.deleted:deleted-topic'], menuVisible: true, SHORT_POST: 1200, postStream: Em.computed.alias('controller.postStream'), updateBar: function() { var $topicProgress = $('#topic-progress'); if (!$topicProgress.length) return; var totalWidth = $topicProgress.width(); var progressWidth = this.get('controller.streamPercentage') * totalWidth; $topicProgress.find('.bg') .css("border-right-width", (progressWidth === totalWidth) ? "0px" : "1px") .width(progressWidth); }.observes('controller.streamPercentage'), updateTitle: function() { var title = this.get('topic.title'); if (title) return Discourse.set('title', title); }.observes('topic.loaded', 'topic.title'), currentPostChanged: function() { var current = this.get('controller.currentPost'); var topic = this.get('topic'); if (!(current && topic)) return; if (current > (this.get('maxPost') || 0)) { this.set('maxPost', current); } var postUrl = topic.get('url'); if (current > 1) { postUrl += "/" + current; } // TODO: @Robin, this should all be integrated into the router, // the view should not be performing routing work // // This workaround ensures the router is aware the route changed, // without it, the up button was broken on long topics. // To repro, go to a topic with 50 posts, go to first post, // scroll to end, click up button ... nothing happens var handler =_.first( _.where(Discourse.URL.get("router.router.currentHandlerInfos"), function(o) { return o.name === "topic.fromParams"; }) ); if(handler){ handler.context = {nearPost: current}; } Discourse.URL.replaceState(postUrl); }.observes('controller.currentPost', 'highest_post_number'), composeChanged: function() { var composerController = Discourse.get('router.composerController'); composerController.clearState(); composerController.set('topic', this.get('topic')); }.observes('composer'), enteredTopic: function() { if (this.present('controller.enteredAt')) { var topicView = this; Em.run.schedule('afterRender', function() { topicView.updateBar(); topicView.updatePosition(); }); } }.observes('controller.enteredAt'), didInsertElement: function(e) { this.bindScrolling({debounce: 0}); var topicView = this; $(window).bind('resize.discourse-on-scroll', function() { topicView.updatePosition(); }); this.$().on('mouseup.discourse-redirect', '.cooked a, a.track-link', function(e) { return Discourse.ClickTrack.trackClick(e); }); }, // This view is being removed. Shut down operations willDestroyElement: function() { this.unbindScrolling(); $(window).unbind('resize.discourse-on-scroll'); // Unbind link tracking this.$().off('mouseup.discourse-redirect', '.cooked a, a.track-link'); this.resetExamineDockCache(); // this happens after route exit, stuff could have trickled in this.set('controller.controllers.header.showExtraInfo', false); }, debounceLoadSuggested: Discourse.debounce(function(){ if (this.get('isDestroyed') || this.get('isDestroying')) { return; } var incoming = this.get('topicTrackingState.newIncoming'); var suggested = this.get('topic.details.suggested_topics'); var topicId = this.get('topic.id'); if(suggested) { var existing = _.invoke(suggested, 'get', 'id'); var lookup = _.chain(incoming) .last(5) .reverse() .union(existing) .uniq() .without(topicId) .first(5) .value(); Discourse.TopicList.loadTopics(lookup, "").then(function(topics){ suggested.clear(); suggested.pushObjects(topics); }); } }, 1000), hasNewSuggested: function(){ this.debounceLoadSuggested(); }.observes('topicTrackingState.incomingCount'), gotFocus: function(){ if (Discourse.get('hasFocus')){ this.scrolled(); } }.observes("Discourse.hasFocus"), getPost: function($post){ var post, postView; postView = Ember.View.views[$post.prop('id')]; if (postView) { return postView.get('post'); } return null; }, // Called for every post seen, returns the post number postSeen: function($post) { var post = this.getPost($post); if (post) { var postNumber = post.get('post_number'); if (postNumber > (this.get('controller.last_read_post_number') || 0)) { this.set('controller.last_read_post_number', postNumber); } if (!post.get('read')) { post.set('read', true); } return post.get('post_number'); } }, resetExamineDockCache: function() { this.set('docAt', false); }, updateDock: function(postView) { if (!postView) return; var post = postView.get('post'); if (!post) return; this.set('controller.progressPosition', this.get('postStream').indexOf(post) + 1); }, throttledPositionUpdate: Discourse.debounce(function() { Discourse.ScreenTrack.instance().scrolled(); var model = this.get('controller.model'); if (model && this.get('nextPositionUpdate')) { this.set('controller.currentPost', this.get('nextPositionUpdate')); } },500), scrolled: function(){ this.updatePosition(); }, /** Process the posts the current user has seen in the topic. @private @method processSeenPosts **/ processSeenPosts: function() { var rows = $('.topic-post.ready'); if (!rows || rows.length === 0) { return; } // if we have no rows var info = Discourse.Eyeline.analyze(rows); if(!info) { return; } // are we scrolling upwards? if(info.top === 0 || info.onScreen[0] === 0 || info.bottom === 0) { var $body = $('body'); var $elem = $(rows[0]); var distToElement = $body.scrollTop() - $elem.position().top; this.get('postStream').prependMore().then(function() { Em.run.next(function () { $('html, body').scrollTop($elem.position().top + distToElement); }); }); } // are we scrolling down? var currentPost; if(info.bottom === rows.length-1) { currentPost = this.postSeen($(rows[info.bottom])); this.get('postStream').appendMore(); } // update dock this.updateDock(Ember.View.views[rows[info.bottom].id]); // mark everything on screen read var topicView = this; _.each(info.onScreen,function(item){ var seen = topicView.postSeen($(rows[item])); currentPost = currentPost || seen; }); var currentForPositionUpdate = currentPost; if (!currentForPositionUpdate) { var postView = this.getPost($(rows[info.bottom])); if (postView) { currentForPositionUpdate = postView.get('post_number'); } } if (currentForPositionUpdate) { this.set('nextPositionUpdate', currentPost || currentForPositionUpdate); this.throttledPositionUpdate(); } else { console.error("can't update position "); } }, /** The user has scrolled the window, or it is finished rendering and ready for processing. @method updatePosition **/ updatePosition: function() { this.processSeenPosts(); var offset = window.pageYOffset || $('html').scrollTop(); if (!this.get('docAt')) { var title = $('#topic-title'); if (title && title.length === 1) { this.set('docAt', title.offset().top); } } var headerController = this.get('controller.controllers.header'), topic = this.get('controller.model'); if (this.get('docAt')) { headerController.set('showExtraInfo', offset >= this.get('docAt') || topic.get('postStream.firstPostNotLoaded')); } else { headerController.set('showExtraInfo', topic.get('postStream.firstPostNotLoaded')); } // Dock the counter if necessary var $lastPost = $('article[data-post-id=' + topic.get('postStream.lastPostId') + "]"); var lastPostOffset = $lastPost.offset(); if (!lastPostOffset) { this.set('controller.dockedCounter', false); return; } this.set('controller.dockedCounter', (offset >= (lastPostOffset.top + $lastPost.height()) - $(window).height())); }, topicTrackingState: function() { return Discourse.TopicTrackingState.current(); }.property(), browseMoreMessage: function() { var opts = { latestLink: "" + (I18n.t("topic.view_latest_topics")) + "" }; var category = this.get('controller.content.category'); if (category) { opts.catLink = Discourse.Utilities.categoryLink(category); } else { opts.catLink = "" + (I18n.t("topic.browse_all_categories")) + ""; } var tracking = this.get('topicTrackingState'); var unreadTopics = tracking.countUnread(); var newTopics = tracking.countNew(); if (newTopics + unreadTopics > 0) { var hasBoth = unreadTopics > 0 && newTopics > 0; return I18n.messageFormat("topic.read_more_MF", { "BOTH": hasBoth, "UNREAD": unreadTopics, "NEW": newTopics, "CATEGORY": category ? true : false, latestLink: opts.latestLink, catLink: opts.catLink }); } else if (category) { return I18n.t("topic.read_more_in_category", opts); } else { return I18n.t("topic.read_more", opts); } }.property('topicTrackingState.messageCount') }); Discourse.TopicView.reopenClass({ // Scroll to a given post, if in the DOM. Returns whether it was in the DOM or not. jumpToPost: function(topicId, postNumber, avoidScrollIfPossible) { Em.run.scheduleOnce('afterRender', function() { var rows = $('.topic-post.ready'); // Make sure we're looking at the topic we want to scroll to if (topicId !== parseInt($('#topic').data('topic-id'), 10)) { return false; } var $post = $("#post_" + postNumber); if ($post.length) { var postTop = $post.offset().top; var highlight = true; var header = $('header'); var title = $('#topic-title'); var expectedOffset = title.height() - header.find('.contents').height(); if (expectedOffset < 0) { expectedOffset = 0; } var offset = (header.outerHeight(true) + expectedOffset); var windowScrollTop = $('html, body').scrollTop(); if (avoidScrollIfPossible && postTop > windowScrollTop + offset && postTop < windowScrollTop + $(window).height() + 100) { // in view } else { // not in view ... bring into view if (postNumber === 1) { $(window).scrollTop(0); highlight = false; } else { var desired = $post.offset().top - offset; $(window).scrollTop(desired); // TODO @Robin, I am seeing multiple events in chrome issued after // jumpToPost if I refresh a page, sometimes I see 2, sometimes 3 // // 1. Where are they coming from? // 2. On refresh we should only issue a single scrollTop // 3. If you are scrolled down in BoingBoing desired sometimes is wrong // due to vanishing header, we should not be rendering it imho until after // we render the posts var first = true; var t = new Date(); // console.log("DESIRED:" + desired); var enforceDesired = function(){ if($(window).scrollTop() !== desired) { console.log("GOT EVENT " + $(window).scrollTop()); console.log("Time " + (new Date() - t)); console.trace(); if(first) { $(window).scrollTop(desired); first = false; } // $(document).unbind("scroll", enforceDesired); } }; // uncomment this line to help debug this issue. // $(document).scroll(enforceDesired); } } if(highlight) { var $contents = $('.topic-body .contents', $post); var origColor = $contents.data('orig-color') || $contents.css('backgroundColor'); $contents.data("orig-color", origColor); $contents .css({ backgroundColor: "#ffffcc" }) .stop() .animate({ backgroundColor: origColor }, 2500); } } }); } });