Current File : /home/jvzmxxx/wiki/extensions/Flow/modules/engine/components/board/features/flow-board-loadmore.js
/*!
 * Contains loadMore, jumpToTopic, and topic titles list functionality.
 */

( function ( $, mw, moment ) {
	/**
	 * Bind UI events and infinite scroll handler for load more and titles list functionality.
	 * @param {jQuery} $container
	 * @this FlowBoardComponent
	 * @constructor
	 */
	function FlowBoardComponentLoadMoreFeatureMixin( $container ) {
		/** Stores a reference to each topic element currently on the page */
		this.renderedTopics = {};
		/** Stores a list of all topics titles by ID */
		this.topicTitlesById = {};
		/** Stores a list of all topic IDs in order */
		this.orderedTopicIds = [];

		this.bindNodeHandlers( FlowBoardComponentLoadMoreFeatureMixin.UI.events );
	}
	OO.initClass( FlowBoardComponentLoadMoreFeatureMixin );

	FlowBoardComponentLoadMoreFeatureMixin.UI = {
		events: {
			apiPreHandlers: {},
			apiHandlers: {},
			loadHandlers: {}
		}
	};

	//
	// Prototype methods
	//

	/**
	 * Scrolls up or down to a specific topic, and loads any topics it needs to.
	 * 1. If topic is rendered, scrolls to it.
	 * 2. Otherwise, we load the topic itself
	 * 3b. When the user scrolls up, we begin loading the topics in between.
	 * @param {string} topicId
	 */
	function flowBoardComponentLoadMoreFeatureJumpTo( topicId ) {
		/** @type FlowBoardComponent*/
		var apiParameters,
			flowBoard = this,
			// Scrolls to the given topic, but disables infinite scroll loading while doing so
			_scrollWithoutInfinite = function () {
				var $renderedTopic = flowBoard.renderedTopics[ topicId ];

				if ( $renderedTopic && $renderedTopic.length ) {
					flowBoard.infiniteScrollDisabled = true;

					// Get out of the way of the affixed navigation
					// Not going the full $( '.flow-board-navigation' ).height()
					// because then the load more button (above the new topic)
					// would get in sight and any scroll would fire it
					$( 'html, body' ).scrollTop( $renderedTopic.offset().top - 20 );

					// Focus on given topic
					$renderedTopic.click().focus();

					/*
					 * Re-enable infinite scroll. Only doing that after a couple
					 * of milliseconds because we've just executed some
					 * scrolling (to the selected topic) and the very last
					 * scroll event may only just still be getting fired.
					 * To prevent an immediate scroll (above the new topic),
					 * let's only re-enable infinite scroll until we're sure
					 * that event has been fired.
					 */
					setTimeout( function () {
						delete flowBoard.infiniteScrollDisabled;
					}, 1 );
				} else {
					flowBoard.debug( 'Rendered topic not found when attempting to scroll!' );
				}
			};

		// 1. Topic is already on the page; just scroll to it
		if ( flowBoard.renderedTopics[ topicId ] ) {
			_scrollWithoutInfinite();
			return;
		}

		// 2a. Topic is not rendered; do we know about this topic ID?
		if ( flowBoard.topicTitlesById[ topicId ] === undefined ) {
			// We don't. Abort!
			return flowBoard.debug( 'Unknown topicId', arguments );
		}

		// 2b. Load that topic and jump to it
		apiParameters = {
			action: 'flow',
			submodule: 'view-topiclist',
			'vtloffset-dir': 'fwd', // @todo support "middle" dir
			'vtlinclude-offset': true,
			vtlsortby: this.topicIdSort
		};

		if ( this.topicIdSort === 'newest' ) {
			apiParameters[ 'vtloffset-id' ] = topicId;
		} else {
			// TODO: It would seem to be safer to pass 'offset-id' for both (what happens
			// if there are two posts at the same timestamp?).  (Also, that would avoid needing
			// the timestamp in the TOC-only API response).  However, currently
			// we must pass 'offset' for 'updated' order to get valid results.

			apiParameters.vtloffset = moment.utc( this.updateTimestampsByTopicId[ topicId ] ).format( 'YYYYMMDDHHmmss' );
		}

		flowBoard.Api.apiCall( apiParameters )
			// TODO: Finish this error handling or remove the empty functions.
			// Remove the load indicator
			.always( function () {
				// @todo support for multiple indicators on same target
				// $target.removeClass( 'flow-api-inprogress' );
				// $this.removeClass( 'flow-api-inprogress' );
			} )
			// On success, render the topic
			.done( function ( data ) {
				_flowBoardComponentLoadMoreFeatureRenderTopics(
					flowBoard,
					data.flow[ 'view-topiclist' ].result.topiclist,
					false,
					null,
					'',
					'',
					'flow_topiclist_loop.partial' // @todo clean up the way we pass these 3 params ^
				);

				_scrollWithoutInfinite();
			} )
			// On fail, render an error
			.fail( function ( code, data ) {
				flowBoard.debug( true, 'Failed to load topics: ' + code );
				// Failed fetching the new data to be displayed.
				// @todo render the error at topic position and scroll to it
				// @todo how do we render this?
				// $target = ????
				// flowBoard.emitWithReturn( 'removeError', $target );
				// var errorMsg = flowBoard.constructor.static.getApiErrorMessage( code, result );
				// errorMsg = mw.msg( '????', errorMsg );
				// flowBoard.emitWithReturn( 'showError', $target, errorMsg );
			} );
	}
	FlowBoardComponentLoadMoreFeatureMixin.prototype.jumpToTopic = flowBoardComponentLoadMoreFeatureJumpTo;

	//
	// API pre-handlers
	//

	/**
	 * On before board reloading (eg. change sort).
	 * This method only clears the storage in preparation for it to be reloaded.
	 * @param {Event} event
	 * @param {Object} info
	 * @param {jQuery} info.$target
	 * @param {Object} queryMap
	 * @param {FlowBoardComponent} info.component
	 */
	function flowBoardComponentLoadMoreFeatureBoardApiPreHandler( event, info, queryMap ) {
		// Backup the topic data
		info.component.renderedTopicsBackup = info.component.renderedTopics;
		info.component.topicTitlesByIdBackup = info.component.topicTitlesById;
		// Reset the topic data
		info.component.renderedTopics = {};
		info.component.topicTitlesById = {};
	}
	FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiPreHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiPreHandler;

	//
	// API callback handlers
	//

	/**
	 * On failed board reloading (eg. change sort), restore old data.
	 * @param {Object} info
	 * @param {string} info.status "done" or "fail"
	 * @param {jQuery} info.$target
	 * @param {FlowBoardComponent} info.component
	 * @param {Object} data
	 * @param {jqXHR} jqxhr
	 * @return {jQuery.Promise}
	 */
	function flowBoardComponentLoadMoreFeatureBoardApiCallback( info, data, jqxhr ) {
		if ( info.status !== 'done' ) {
			// Failed; restore the topic data
			info.component.renderedTopics = info.component.renderedTopicsBackup;
			info.component.topicTitlesById = info.component.topicTitlesByIdBackup;
		}

		// Delete the backups
		delete info.component.renderedTopicsBackup;
		delete info.component.topicTitlesByIdBackup;
	}
	FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.board = flowBoardComponentLoadMoreFeatureBoardApiCallback;

	/**
	 * Loads more content
	 * @param {Object} info
	 * @param {string} info.status "done" or "fail"
	 * @param {jQuery} info.$target
	 * @param {FlowBoardComponent} info.component
	 * @param {Object} data
	 * @param {jqXHR} jqxhr
	 */
	function flowBoardComponentLoadMoreFeatureTopicsApiCallback( info, data, jqxhr ) {
		if ( info.status !== 'done' ) {
			// Error will be displayed by default, nothing else to wrap up
			return $.Deferred().resolve().promise();
		}

		var $this = $( this ),
			$target = info.$target,
			flowBoard = info.component,
			scrollTarget = $this.data( 'flow-scroll-target' ),
			$scrollContainer = $.findWithParent( $this, $this.data( 'flow-scroll-container' ) ),
			topicsData = data.flow[ 'view-topiclist' ].result.topiclist,
			readingTopicPosition;

		if ( scrollTarget === 'window' && flowBoard.readingTopicId ) {
			// Store the current position of the topic you are reading
			readingTopicPosition = { id: flowBoard.readingTopicId };
			// Where does the topic start?
			readingTopicPosition.topicStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
			// Where am I within the topic?
			readingTopicPosition.topicPlace = $( window ).scrollTop() - readingTopicPosition.topicStart;
		}

		// Render topics
		_flowBoardComponentLoadMoreFeatureRenderTopics(
			flowBoard,
			topicsData,
			flowBoard.$container.find( flowBoard.$loadMoreNodes ).last()[ 0 ] === this, // if this is the last load more button
			$target,
			scrollTarget,
			$this.data( 'flow-scroll-container' ),
			$this.data( 'flow-template' )
		);

		// Remove the old load button (necessary if the above load_more template returns nothing)
		$target.remove();

		if ( scrollTarget === 'window' ) {
			scrollTarget = $( window );

			if ( readingTopicPosition ) {
				readingTopicPosition.anuStart = flowBoard.renderedTopics[ readingTopicPosition.id ].offset().top;
				if ( readingTopicPosition.anuStart > readingTopicPosition.topicStart ) {
					// Looks like the topic we are reading got pushed down. Let's jump to where we were before
					scrollTarget.scrollTop( readingTopicPosition.anuStart + readingTopicPosition.topicPlace );
				}
			}
		} else {
			scrollTarget = $.findWithParent( this, scrollTarget );
		}

		/*
		 * Fire infinite scroll check again - if no (or few) topics were
		 * added (e.g. because they're moderated), we should immediately
		 * fetch more instead of waiting for the user to scroll again (when
		 * there's no reason to scroll)
		 */
		_flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( flowBoard, $scrollContainer, scrollTarget );
		return $.Deferred().resolve().promise();
	}
	FlowBoardComponentLoadMoreFeatureMixin.UI.events.apiHandlers.loadMoreTopics = flowBoardComponentLoadMoreFeatureTopicsApiCallback;

	//
	// On element-load handlers
	//

	/**
	 * Stores the load more button for use with infinite scroll.
	 *
	 *     <button data-flow-scroll-target="< ul"></button>
	 *
	 * @param {jQuery} $button
	 */
	function flowBoardComponentLoadMoreFeatureElementLoadCallback( $button ) {
		var scrollTargetSelector = $button.data( 'flow-scroll-target' ),
			$target,
			scrollContainerSelector = $button.data( 'flow-scroll-container' ),
			$scrollContainer = $.findWithParent( $button, scrollContainerSelector ),
			board = this;

		if ( !this.$loadMoreNodes ) {
			// Create a new $loadMoreNodes list
			this.$loadMoreNodes = $();
		} else {
			// Remove any loadMore nodes that are no longer in the body
			this.$loadMoreNodes = this.$loadMoreNodes.filter( function () {
				var $this = $( this );

				// @todo unbind scroll handlers
				if ( !$this.closest( 'body' ).length ) {
					// Get rid of this and its handlers
					$this.remove();
					// Delete from list
					return false;
				}

				return true;
			} );
		}

		// Store this new loadMore node
		this.$loadMoreNodes = this.$loadMoreNodes.add( $button );

		// Make sure we didn't already bind to this element's scroll previously
		if ( $scrollContainer.data( 'scrollIsBound' ) ) {
			return;
		}
		$scrollContainer.data( 'scrollIsBound', true );

		// Bind the event for this
		if ( scrollTargetSelector === 'window' ) {
			this.on( 'windowScroll', function () {
				_flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $( window ) );
			} );
		} else {
			$target = $.findWithParent( $button, scrollTargetSelector );
			$target.on( 'scroll.flow-load-more', $.throttle( 50, function ( evt ) {
				_flowBoardComponentLoadMoreFeatureInfiniteScrollCheck.call( board, $scrollContainer, $target );
			} ) );
		}
	}
	FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.loadMore = flowBoardComponentLoadMoreFeatureElementLoadCallback;

	/**
	 * Stores a list of all topics currently visible on the page.
	 * @param {jQuery} $topic
	 */
	function flowBoardComponentLoadMoreFeatureElementLoadTopic( $topic ) {
		var self = this,
			currentTopicId = $topic.data( 'flow-id' );

		// Store this topic by ID
		this.renderedTopics[ currentTopicId ] = $topic;

		// Remove any topics that are no longer on the page, just in case
		$.each( this.renderedTopics, function ( topicId, $topic ) {
			if ( !$topic.closest( self.$board ).length ) {
				delete self.renderedTopics[ topicId ];
			}
		} );
	}
	FlowBoardComponentLoadMoreFeatureMixin.UI.events.loadHandlers.topic = flowBoardComponentLoadMoreFeatureElementLoadTopic;

	//
	// Private functions
	//

	/**
	 * Generates Array#sort callback for sorting a list of topic ids
	 * by the 'recently active' sort order. This is a numerical
	 * comparison of related timestamps held within the board object.
	 * Also note that this is a reverse sort from newest to oldest.
	 *
	 * @private
	 *
	 * @param {Object} board Object from which to source
	 *  timestamps which map from topicId to its last updated timestamp
	 * @return {Function} Sort callback
	 * @return {string} return.a
	 * @return {string} return.b
	 * @return {number} return.return Per Array#sort callback rules
	 */
	function _flowBoardTopicIdGenerateSortRecentlyActive( board ) {
		return function ( a, b ) {
			var aTimestamp = board.updateTimestampsByTopicId[ a ],
				bTimestamp = board.updateTimestampsByTopicId[ b ];

			if ( aTimestamp === undefined && bTimestamp === undefined ) {
				return 0;
			} else if ( aTimestamp === undefined ) {
				return 1;
			} else if ( bTimestamp === undefined ) {
				return -1;
			} else {
				return bTimestamp - aTimestamp;
			}
		};
	}

	/**
	 * Re-sorts the orderedTopicIds after insert
	 *
	 * @param {Object} flowBoard
	 */
	function _flowBoardSortTopicIds( flowBoard ) {
		var topicIdSortCallback;

		if ( flowBoard.topicIdSort === 'updated' ) {
			topicIdSortCallback = _flowBoardTopicIdGenerateSortRecentlyActive( flowBoard );

			// Custom sorts
			flowBoard.orderedTopicIds.sort( topicIdSortCallback );
		} else {
			// Default sort, takes advantage of topic ids monotonically increasing
			// which allows for the newest sort to be the default utf-8 string sort
			// in reverse.
			// TODO: This can be optimized (to avoid two in-place operations that affect
			// the whole array by doing a descending sort (with a custom comparator)
			// rather than sorting then reversing.
			flowBoard.orderedTopicIds.sort().reverse();
		}
	}
	FlowBoardComponentLoadMoreFeatureMixin.prototype.sortTopicIds = _flowBoardSortTopicIds;

	/**
	 * Called on scroll. Checks to see if a FlowBoard needs to have more content loaded.
	 * @param {jQuery} $searchContainer Container to find 'load more' buttons in
	 * @param {jQuery} $calculationContainer Container to do scroll calculations on (height, scrollTop, offset, etc.)
	 */
	function _flowBoardComponentLoadMoreFeatureInfiniteScrollCheck( $searchContainer, $calculationContainer ) {
		if ( this.infiniteScrollDisabled ) {
			// This happens when the topic navigation is used to jump to a topic
			// We should not infinite-load anything when we are scrolling to a topic
			return;
		}

		var calculationContainerHeight = $calculationContainer.height(),
			calculationContainerScroll = $calculationContainer.scrollTop(),
			calculationContainerThreshold = ( $calculationContainer.offset() || { top: calculationContainerScroll } ).top;

		// Find load more buttons within our search container, and they must be visible
		$searchContainer.find( this.$loadMoreNodes ).filter( ':visible' ).each( function () {
			var $this = $( this ),
				nodeOffset = $this.offset().top,
				nodeHeight = $this.outerHeight( true );

			// First, is this element above or below us?
			if ( nodeOffset <= calculationContainerThreshold ) {
				// Top of element is above the viewport; don't use it.
				return;
			}

			// @todo: this ignores that TOC also obscures the button: load more
			// also shouldn't be triggered if it's still behind TOC!

			// Is this element in the viewport?
			if ( nodeOffset - nodeHeight <= calculationContainerThreshold + calculationContainerHeight ) {
				// Element is almost in viewport, click it.
				$( this ).trigger( 'click' );
			}
		} );
	}

	/**
	 * Renders and inserts a list of new topics.
	 * @param {FlowBoardComponent} flowBoard
	 * @param {Object} topicsData
	 * @param {boolean} [forceShowLoadMore]
	 * @param {jQuery} [$insertAt]
	 * @param {string} [scrollTarget]
	 * @param {string} [scrollContainer]
	 * @param {string} [scrollTemplate]
	 * @private
	 */
	function _flowBoardComponentLoadMoreFeatureRenderTopics( flowBoard, topicsData, forceShowLoadMore, $insertAt, scrollTarget, scrollContainer, scrollTemplate ) {
		if ( !topicsData.roots.length ) {
			flowBoard.debug( 'No topics returned from API', arguments );
			return;
		}

		/** @private
		 */
		function _createRevPagination( $target ) {
			if ( !topicsData.links.pagination.fwd && !topicsData.links.pagination.rev ) {
				return;
			}

			if ( !topicsData.links.pagination.rev && topicsData.links.pagination.fwd ) {
				// This is a fix for the fact that a "rev" is not available here (TODO: Why not?)
				// We can create one by overriding dir=rev
				topicsData.links.pagination.rev = $.extend( true, {}, topicsData.links.pagination.fwd, { title: 'rev' } );
				topicsData.links.pagination.rev.url = topicsData.links.pagination.rev.url.replace( '_offset-dir=fwd', '_offset-dir=rev' );
			}

			$allRendered = $allRendered.add(
				$( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
					'flow_load_more.partial',
					{
						loadMoreObject: topicsData.links.pagination.rev,
						loadMoreApiHandler: 'loadMoreTopics',
						loadMoreTarget: scrollTarget,
						loadMoreContainer: scrollContainer,
						loadMoreTemplate: scrollTemplate
					}
				) ).children()
					.insertBefore( $target.first() )
			);
		}

		/** @private
		 */
		function _createFwdPagination( $target ) {
			if ( forceShowLoadMore || topicsData.links.pagination.fwd ) {
				// Add the load more to the end of the stack
				$allRendered = $allRendered.add(
					$( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
						'flow_load_more.partial',
						{
							loadMoreObject: topicsData.links.pagination.fwd,
							loadMoreApiHandler: 'loadMoreTopics',
							loadMoreTarget: scrollTarget,
							loadMoreContainer: scrollContainer,
							loadMoreTemplate: scrollTemplate
						}
					) ).children()
						.insertAfter( $target.last() )
				);
			}
		}

		/**
		 * Renders topics by IDs from topicsData, and returns the elements.
		 * @param {Array} toRender List of topic IDs in topicsData
		 * @return {jQuery}
		 * @private
		 */
		function _render( toRender ) {
			var rootsBackup = topicsData.roots,
				$newTopics;

			// Temporarily set roots to our subset to be rendered
			topicsData.roots = toRender;

			try {
				$newTopics = $( flowBoard.constructor.static.TemplateEngine.processTemplateGetFragment(
					scrollTemplate,
					topicsData
				) ).children();
			} catch ( e ) {
				flowBoard.debug( true, 'Failed to render new topic' );
				$newTopics = $();
			}

			topicsData.roots = rootsBackup;

			return $newTopics;
		}

		var i, j, $topic, topicId,
			$allRendered = $( [] ),
			toInsert = [];

		for ( i = 0; i < topicsData.roots.length; i++ ) {
			topicId = topicsData.roots[ i ];

			if ( !flowBoard.renderedTopics[ topicId ] ) {
				flowBoard.renderedTopics[ topicId ] = _render( [ topicId ] );
				$allRendered.push( flowBoard.renderedTopics[ topicId ][ 0 ] );
				toInsert.push( topicId );
				if ( $.inArray( topicId, flowBoard.orderedTopicIds ) === -1 ) {
					flowBoard.orderedTopicIds.push( topicId );
				}
				// @todo this is already done elsewhere, but it runs after insert
				// to the DOM instead of before.  Not sure how to fix ordering.
				if ( !flowBoard.updateTimestampsByTopicId[ topicId ] ) {
					flowBoard.updateTimestampsByTopicId[ topicId ] = topicsData.revisions[ topicsData.posts[ topicId ][ 0 ] ].last_updated;
				}
			}
		}

		if ( toInsert.length ) {
			_flowBoardSortTopicIds( flowBoard );

			// This uses the assumption that there will be at least one pre-existing
			// topic above the topics to be inserted.  This should hold true as the
			// initial page load starts at the begining.
			for ( i = 1; i < flowBoard.orderedTopicIds.length; i++ ) {
				// topic is not to be inserted yet.
				if ( $.inArray( flowBoard.orderedTopicIds[ i ], toInsert ) === -1 ) {
					continue;
				}

				// find the most recent topic in the list that exists and insert after it.
				for ( j = i - 1; j >= 0; j-- ) {
					$topic = flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ j ] ];
					if ( $topic && $topic.length && $.contains( document.body, $topic[ 0 ] ) ) {
						break;
					}
				}

				// Put the new topic after the found topic above it
				if ( j >= 0 ) {
					$topic.after( flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i ] ] );
				}
			}

			// This works because orderedTopicIds includes not only the topics on
			// page but also the ones loaded by the toc.  If these topics are due
			// to a jump rather than forward auto-pagination the prior topic will
			// not be rendered.
			i = $.inArray( topicsData.roots[ 0 ], flowBoard.orderedTopicIds );
			if ( i > 0 && flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i - 1 ] ] === undefined ) {
				_createRevPagination( flowBoard.renderedTopics[ topicsData.roots[ 0 ] ] );
			}
			// Same for forward pagination, if we jumped and then scrolled backwards the
			// topic after the last will already be rendered, and forward pagination
			// will not be necessary.
			i = $.inArray( topicsData.roots[ topicsData.roots.length - 1 ], flowBoard.orderedTopicIds );
			if ( i === flowBoard.orderedTopicIds.length - 1 || flowBoard.renderedTopics[ flowBoard.orderedTopicIds[ i + 1 ] ] === undefined ) {
				_createFwdPagination( flowBoard.renderedTopics[ topicsData.roots[ topicsData.roots.length - 1 ] ] );
			}

		}

		// Run loadHandlers
		flowBoard.emitWithReturn( 'makeContentInteractive', $allRendered );

		// HACK: Emit an event here so that the flow data model can populate
		// itself based on the API response
		flowBoard.emit( 'loadmore', topicsData );
	}

	// Mixin to FlowBoardComponent
	mw.flow.mixinComponent( 'board', FlowBoardComponentLoadMoreFeatureMixin );
}( jQuery, mediaWiki, moment ) );