diff --git a/vanilla/applications/dashboard/controllers/class.notificationscontroller.php b/vanilla/applications/dashboard/controllers/class.notificationscontroller.php new file mode 100644 index 0000000..0b352a4 --- /dev/null +++ b/vanilla/applications/dashboard/controllers/class.notificationscontroller.php @@ -0,0 +1,111 @@ +Head = new HeadModule($this); + $this->addJsFile('jquery.js'); + $this->addJsFile('jquery.form.js'); + $this->addJsFile('jquery.popup.js'); + $this->addJsFile('jquery.gardenhandleajaxform.js'); + $this->addJsFile('global.js'); + $this->addCssFile('style.css'); + $this->addCssFile('vanillicon.css', 'static'); + $this->addModule('GuestModule'); + parent::initialize(); + } + + /** + * Adds inform messages to response for inclusion in pages dynamically. + * + * @since 2.0.18 + * @access public + */ + public function inform() { + $this->deliveryType(DELIVERY_TYPE_BOOL); + $this->deliveryMethod(DELIVERY_METHOD_JSON); + + // Retrieve all notifications and inform them. + NotificationsController::informNotifications($this); + $this->fireEvent('BeforeInformNotifications'); + + $this->render(); + } + + /** + * Grabs all new notifications and adds them to the sender's inform queue. + * + * This method gets called by dashboard's hooks file to display new + * notifications on every pageload. + * + * @since 2.0.18 + * @access public + * + * @param Gdn_Controller $sender The object calling this method. + */ + public static function informNotifications($sender) { + $session = Gdn::session(); + if (!$session->isValid()) { + return; + } + + $activityModel = new ActivityModel(); + // Get five pending notifications. + $where = [ + 'NotifyUserID' => Gdn::session()->UserID, + 'Notified' => ActivityModel::SENT_PENDING]; + + // If we're in the middle of a visit only get very recent notifications. + $where['DateUpdated >'] = Gdn_Format::toDateTime(strtotime('-5 minutes')); + + $activities = $activityModel->getWhere($where, '', '', 5, 0)->resultArray(); + + $activityIDs = array_column($activities, 'ActivityID'); + $activityModel->setNotified($activityIDs); + + $sender->EventArguments['Activities'] = &$activities; + $sender->fireEvent('InformNotifications'); + + foreach ($activities as $activity) { + if ($activity['Photo']) { + $userPhoto = anchor( + img($activity['Photo'], ['class' => 'ProfilePhotoMedium']), + $activity['Url'], + 'Icon' + ); + } else { + $userPhoto = ''; + } + + $excerpt = ''; + $story = $activity['Story'] ?? null; + $format = $activity['Format'] ?? HtmlFormat::FORMAT_KEY; + $excerpt = htmlspecialchars($story ? Gdn::formatService()->renderExcerpt($story, $format) : $excerpt); + $activityClass = ' Activity-'.$activity['ActivityType']; + + // FIX: https://github.com/topcoder-platform/forums/issues/506 + $sender->informMessage( + $userPhoto + .wrap($activity['Headline'], 'div', ['class' => 'Title']) + //.wrap($excerpt, 'div', ['class' => 'Excerpt']) + ,'Dismissable AutoDismiss'.$activityClass.($userPhoto == '' ? '' : ' HasIcon') + ); + } + } +} diff --git a/vanilla/applications/vanilla/views/categories/helper_functions.php b/vanilla/applications/vanilla/views/categories/helper_functions.php index 7aa1062..30d869e 100644 --- a/vanilla/applications/vanilla/views/categories/helper_functions.php +++ b/vanilla/applications/vanilla/views/categories/helper_functions.php @@ -221,7 +221,7 @@ function writeListItem($category, $depth) { t('%s discussions') ), bigPlural(val('CountAllDiscussions', $category), '%s discussion')) ?> - · + · CountComments, '%s comment')); ?> - · + · CountViews, '%s view html', '%s views html', t('%s view'), t('%s views')), diff --git a/vanilla/js/global.js b/vanilla/js/global.js new file mode 100644 index 0000000..1bd0671 --- /dev/null +++ b/vanilla/js/global.js @@ -0,0 +1,1527 @@ +/** + * Vanilla's legacy javascript core. + * + * @copyright 2009-2018 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +// Global vanilla library function. +(function(window, $) { + + // Prevent auto-execution of scripts when no explicit dataType was provided + // See https://github.com/jquery/jquery/issues/2432#issuecomment-403761229 + jQuery.ajaxPrefilter(function(s) { + if (s.crossDomain) { + s.contents.script = false; + } + }); + + var Vanilla = function() { + }; + + Vanilla.fn = Vanilla.prototype; + + if (!window.console) + window.console = { + log: function() { + } + }; + + Vanilla.scrollTo = function(q) { + var top = $(q).offset().top; + window.scrollTo(0, top); + return false; + }; + + // Add a stub for embedding. + Vanilla.parent = function() { + }; + Vanilla.parent.callRemote = function(func, args, success, failure) { + console.log("callRemote stub: " + func, args); + }; + + window.gdn = window.gdn || {}; + window.Vanilla = Vanilla; + + gdn.getMeta = function(key, defaultValue) { + if (gdn.meta[key] === undefined) { + return defaultValue; + } else { + return gdn.meta[key]; + } + }; + + gdn.setMeta = function(key, value) { + gdn.meta[key] = value; + }; + + var keyString = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + + // See http://ecmanaut.blogspot.de/2006/07/encoding-decoding-utf8-in-javascript.html + var uTF8Encode = function(string) { + return decodeURI(encodeURIComponent(string)); + }; + + // See http://ecmanaut.blogspot.de/2006/07/encoding-decoding-utf8-in-javascript.html + var uTF8Decode = function(string) { + return decodeURIComponent(escape(string)); + }; + + $.extend({ + // private property + keyStr: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", + base64Encode: function(input) { + var output = ""; + var chr1, chr2, chr3, enc1, enc2, enc3, enc4; + var i = 0; + input = uTF8Encode(input); + while (i < input.length) { + chr1 = input.charCodeAt(i++); + chr2 = input.charCodeAt(i++); + chr3 = input.charCodeAt(i++); + enc1 = chr1 >> 2; + enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); + enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); + enc4 = chr3 & 63; + if (isNaN(chr2)) { + enc3 = enc4 = 64; + } else if (isNaN(chr3)) { + enc4 = 64; + } + output = output + keyString.charAt(enc1) + keyString.charAt(enc2) + keyString.charAt(enc3) + keyString.charAt(enc4); + } + return output; + }, + base64Decode: function(input) { + var output = ""; + var chr1, chr2, chr3; + var enc1, enc2, enc3, enc4; + var i = 0; + input = input.replace(/[^A-Za-z0-9\+\/\=]/g, ""); + while (i < input.length) { + enc1 = keyString.indexOf(input.charAt(i++)); + enc2 = keyString.indexOf(input.charAt(i++)); + enc3 = keyString.indexOf(input.charAt(i++)); + enc4 = keyString.indexOf(input.charAt(i++)); + chr1 = (enc1 << 2) | (enc2 >> 4); + chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + chr3 = ((enc3 & 3) << 6) | enc4; + output = output + String.fromCharCode(chr1); + if (enc3 != 64) { + output = output + String.fromCharCode(chr2); + } + if (enc4 != 64) { + output = output + String.fromCharCode(chr3); + } + } + output = uTF8Decode(output); + return output; + } + }); + + /** + * Takes a jQuery function that updates the DOM and the HTML to add. Converts the html to a jQuery object + * and then adds it to the DOM. Triggers 'contentLoad' to allow javascript manipulation of the new DOM elements. + * + * @param func The jQuery function name. + * @param html The html to add. + */ + var funcTrigger = function(func, html) { + this.each(function() { + var $elem = $($.parseHTML(html + '')); // Typecast html to a string and create a DOM node + $(this)[func]($elem); + $elem.trigger('contentLoad'); + }); + return this; + }; + + $.fn.extend({ + appendTrigger: function(html) { + return funcTrigger.call(this, 'append', html); + }, + + beforeTrigger: function(html) { + return funcTrigger.call(this, 'before', html); + }, + + afterTrigger: function(html) { + return funcTrigger.call(this, 'after', html); + }, + + prependTrigger: function(html) { + return funcTrigger.call(this, 'prepend', html); + }, + + htmlTrigger: function(html) { + funcTrigger.call(this, 'html', html); + }, + + replaceWithTrigger: function(html) { + return funcTrigger.call(this, 'replaceWith', html); + } + }); + + $(document).ajaxComplete(function(event, jqXHR, ajaxOptions) { + var csrfToken = jqXHR.getResponseHeader("X-CSRF-Token"); + if (csrfToken) { + gdn.setMeta("TransientKey", csrfToken); + $("input[name=TransientKey]").val(csrfToken); + } + }); + + // Hook into form submissions. Replace element body with server response when we're in a .js-form. + $(document).on("contentLoad", function (e) { + $("form", e.target).submit(function (e) { + var $form = $(this); + + // Traverse up the DOM, starting from the form that triggered the event, looking for the first .js-form. + var $parent = $form.closest(".js-form"); + + // Bail if we aren't in a .js-form. + if ($parent.length === 0) { + return; + } + + // Hijack this submission. + e.preventDefault(); + + // An object containing extra data that should be submitted along with the form. + var data = { + DeliveryType: "VIEW" + }; + + var submitButton = $form.find("input[type=submit]:focus").get(0); + if (submitButton) { + data[submitButton.name] = submitButton.name; + } + + // Send the request, expect HTML and hope for the best. + $form.ajaxSubmit({ + data: data, + dataType: "html", + success: function (data, textStatus, jqXHR) { + $parent.html(data).trigger('contentLoad'); + } + }); + }); + }); + + $(document).on("contentLoad", function (e) { + + // Setup AJAX filtering for flat category module. + // Find each flat category module container, if any. + $(".BoxFlatCategory", e.target).each(function(index, value){ + // Setup the constants we'll need to perform the lookup for this module instance. + var container = value; + var categoryID = $("input[name=CategoryID]", container).val(); + var limit = parseInt($("input[name=Limit]", container).val()); + + // If we don't even have a category, don't bother setting up filtering. + if (typeof categoryID === "undefined") { + return; + } + + // limit was parsed as an int when originally defined. If it isn't a valid value now, default to 10. + if (isNaN(limit) || limit < 1) { + limit = 10; + } + + // Anytime someone types something into the search box in this instance's container... + $(container).on("keyup", ".SearchForm .InputBox", function(filterEvent) { + var url = gdn.url("module/flatcategorymodule/vanilla"); + + // ...perform an AJAX request, replacing the current category data with the result's data. + jQuery.get( + gdn.url("module/flatcategorymodule/vanilla"), + { + categoryID: categoryID, + filter: filterEvent.target.value, + limit: limit + }, + function(data, textStatus, jqXHR) { + $(".FlatCategoryResult", container).replaceWith($(".FlatCategoryResult", data)); + } + ) + }); + }); + + // A vanilla JS event wrapper for the contentLoad event so that the new framework can handle it. + $(document).on("contentLoad", function(e) { + // Don't fire on initial document ready. + if (e.target === document) { + return; + } + + var event = document.createEvent('CustomEvent'); + event.initCustomEvent('X-DOMContentReady', true, false, {}); + e.target.dispatchEvent(event); + }); + }); +})(window, jQuery); + +// Stuff to fire on document.ready(). +jQuery(document).ready(function($) { + + /** + * @deprecated since Vanilla 2.2 + */ + $.postParseJson = function(json) { + return json; + }; + + gdn.focused = true; + gdn.Libraries = {}; + + $(window).blur(function() { + gdn.focused = false; + }); + $(window).focus(function() { + gdn.focused = true; + }); + + // Grab a definition from object in the page + gdn.definition = function(definition, defaultVal, set) { + if (defaultVal === undefined) + defaultVal = definition; + + if (!(definition in gdn.meta)) { + return defaultVal; + } + + if (set) { + gdn.meta[definition] = defaultVal; + } + + return gdn.meta[definition]; + }; + + gdn.disable = function(e, progressClass) { + var href = $(e).attr('href'); + if (href) { + $.data(e, 'hrefBak', href); + } + $(e).addClass(progressClass ? progressClass : 'InProgress').removeAttr('href').attr('disabled', true); + }; + + gdn.enable = function(e) { + $(e).attr('disabled', false).removeClass('InProgress'); + var href = $.data(e, 'hrefBak'); + if (href) { + $(e).attr('href', href); + $.removeData(e, 'hrefBak'); + } + }; + + gdn.elementSupports = function(element, attribute) { + var test = document.createElement(element); + if (attribute in test) + return true; + else + return false; + }; + + gdn.querySep = function(url) { + return url.indexOf('?') == -1 ? '?' : '&'; + }; + + // password strength check + gdn.password = function(password, username) { + var translations = gdn.definition('PasswordTranslations', 'Too Short,Contains Username,Very Weak,Weak,Ok,Good,Strong').split(','); + + // calculate entropy + var alphabet = 0; + if (password.match(/[0-9]/)) + alphabet += 10; + if (password.match(/[a-z]/)) + alphabet += 26; + if (password.match(/[A-Z]/)) + alphabet += 26; + if (password.match(/[^a-zA-Z0-9]/)) + alphabet += 31; + var natLog = Math.log(Math.pow(alphabet, password.length)); + var entropy = natLog / Math.LN2; + + var response = { + pass: false, + symbols: alphabet, + entropy: entropy, + score: 0 + }; + + // reject on length + var length = password.length; + response.length = length; + var requiredLength = gdn.definition('MinPassLength', 6); + var requiredScore = gdn.definition('MinPassScore', 2); + response.required = requiredLength; + if (length < requiredLength) { + response.reason = translations[0]; + return response; + } + + // password1 == username + if (username) { + if (password.toLowerCase().indexOf(username.toLowerCase()) >= 0) { + response.reason = translations[1]; + return response; + } + } + + if (entropy < 30) { + response.score = 1; + response.reason = translations[2]; // very weak + } else if (entropy < 40) { + response.score = 2; + response.reason = translations[3]; // weak + } else if (entropy < 55) { + response.score = 3; + response.reason = translations[4]; // ok + } else if (entropy < 70) { + response.score = 4; + response.reason = translations[5]; // good + } else { + response.score = 5; + response.reason = translations[6]; // strong + } + + return response; + }; + + // Go to notifications if clicking on a user's notification count + $('li.UserNotifications a span').click(function() { + document.location = gdn.url('/profile/notifications'); + return false; + }); + + /** + * Add `rel='noopener'` to everything on the page. + * + * If you really need the linked page to have window.opener, set the `data-allow-opener='true'` on your link. + */ + $("a[target='_blank']") + .filter(":not([rel*='noopener']):not([data-allow-opener='true'])") + .each(function() { + var $this = $(this); + var rel = $this.attr("rel"); + + if (rel) { + $this.attr("rel", rel + " noopener"); + } else { + $this.attr("rel", "noopener"); + } + }); + + // This turns any anchor with the "Popup" class into an in-page pop-up (the + // view of the requested in-garden link will be displayed in a popup on the + // current screen). + if ($.fn.popup) { + + // Previously, jquery.popup used live() to attach events, even to elements + // that do not yet exist. live() has been deprecated. Vanilla upgraded + // jQuery to version 1.10.2, which removed a lot of code. Instead, event + // delegation will need to be used, which means everywhere that Popup + // is called, will need to have a very high up parent delegate to it. + //$('a.Popup').popup(); + //$('a.PopConfirm').popup({'confirm' : true, 'followConfirm' : true}); + + $('a.Popup:not(.dashboard a.Popup):not(.Section-Dashboard a.Popup)').popup(); + $('a.PopConfirm').popup({'confirm': true, 'followConfirm': true}); + } + + $(document).delegate(".PopupWindow:not(.Message .PopupWindow)", 'click', function() { + var $this = $(this); + + if ($this.hasClass('NoMSIE') && /msie/.test(navigator.userAgent.toLowerCase())) { + return; + } + + var width = $this.attr('popupWidth'); + width = width ? width : 960; + var height = $this.attr('popupHeight'); + height = height ? height : 600; + var left = (screen.width - width) / 2; + var top = (screen.height - height) / 2; + + var id = $this.attr('id'); + var href = $this.attr('href'); + if ($this.attr('popupHref')) + href = $this.attr('popupHref'); + else + href += gdn.querySep(href) + 'display=popup'; + + var win = window.open(href, 'Window_' + id, "left=" + left + ",top=" + top + ",width=" + width + ",height=" + height + ",status=0,scrollbars=0"); + if (win) + win.focus(); + return false; + }); + + // This turns any anchor with the "Popdown" class into an in-page pop-up, but + // it does not hijack forms in the popup. + if ($.fn.popup) + $('a.Popdown').popup({hijackForms: false}); + + // This turns SignInPopup anchors into in-page popups + if ($.fn.popup) + $('a.SignInPopup').popup({containerCssClass: 'SignInPopup'}); + + if ($.fn.popup) + $(document).delegate('.PopupClose', 'click', function(event) { + var Popup = $(event.target).parents('.Popup'); + if (Popup.length) { + var PopupID = Popup.prop('id'); + $.popup.close({popupId: PopupID}); + } + }); + + // Make sure that message dismissalls are ajax'd + $(document).delegate('a.Dismiss', 'click', function() { + var anchor = this; + var container = $(anchor).parent(); + var transientKey = gdn.definition('TransientKey'); + var data = 'DeliveryType=BOOL&TransientKey=' + transientKey; + $.post($(anchor).attr('href'), data, function(response) { + if (response == 'TRUE') + $(container).fadeOut('fast', function() { + $(this).remove(); + }); + }); + return false; + }); + + // This turns any form into a "post-in-place" form so it is ajaxed to save + // without a refresh. The form must be within an element with the "AjaxForm" + // class. + if ($.fn.handleAjaxForm) + $('.AjaxForm').handleAjaxForm(); + + // Handle ToggleMenu toggling and set up default state + $('[class^="Toggle-"]').hide(); // hide all toggle containers + $('.ToggleMenu a').click(function() { + // Make all toggle buttons and toggle containers inactive + $(this).parents('.ToggleMenu').find('li').removeClass('Active'); + $('[class^="Toggle-"]').hide(); + var item = $(this).parents('li'); // Identify the clicked container + // The selector of the container that should be revealed. + var containerSelector = '.Toggle-' + item.attr('class'); + containerSelector = containerSelector.replace(/Handle-/gi, ''); + // Reveal the container & make the button active + item.addClass('Active'); // Make the clicked form button active + $(containerSelector).show(); + return false; + }); + $('.ToggleMenu .Active a').click(); // reveal the currently active item. + + // Show hoverhelp on hover + $('.HoverHelp').hover( + function() { + $(this).find('.Help').show(); + }, + function() { + $(this).find('.Help').hide(); + } + ); + + // If a page loads with a hidden redirect url, go there after a few moments. + var redirectTo = gdn.getMeta('RedirectTo', ''); + var checkPopup = gdn.getMeta('CheckPopup', false); + if (redirectTo !== '') { + if (checkPopup && window.opener) { + window.opener.location = redirectTo; + window.close(); + } else { + document.location = redirectTo; + } + } + + // Make tables sortable if the tableDnD plugin is present. + if ($.tableDnD) + $("table.Sortable").tableDnD({ + onDrop: function(table, row) { + var tableId = $($.tableDnD.currentTable).attr('id'); + // Add in the transient key for postback authentication + var transientKey = gdn.definition('TransientKey'); + var data = $.tableDnD.serialize() + '&TableID=' + tableId + '&TransientKey=' + transientKey; + var webRoot = gdn.definition('WebRoot', ''); + $.post( + gdn.url('/utility/sort.json'), + data, + function(response) { + if (response.Result) { + $('#' + tableId + ' tbody tr td').effect("highlight", {}, 1000); + } + } + ); + } + }); + + // Make sure that the commentbox & aboutbox do not allow more than 1000 characters + $.fn.setMaxChars = function(iMaxChars) { + $(this).bind('keyup', function() { + var txt = $(this).val(); + if (txt.length > iMaxChars) + $(this).val(txt.substr(0, iMaxChars)); + }); + }; + + // Generate a random string of specified length + gdn.generateString = function(length) { + if (length === undefined) + length = 5; + + var chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%*'; + var string = ''; + var pos = 0; + for (var i = 0; i < length; i++) { + pos = Math.floor(Math.random() * chars.length); + string += chars.substring(pos, pos + 1); + } + return string; + }; + + // Combine two paths and make sure that there is only a single directory concatenator + gdn.combinePaths = function(path1, path2) { + if (path1.substr(-1, 1) == '/') + path1 = path1.substr(0, path1.length - 1); + + if (path2.substring(0, 1) == '/') + path2 = path2.substring(1); + + return path1 + '/' + path2; + }; + + gdn.processTargets = function(targets, $elem, $parent) { + if (!targets || !targets.length) + return; + + var tar = function(q) { + switch (q) { + case '!element': + return $elem; + case '!parent': + return $parent; + default: + return q; + } + }, + item, + $target; + + for (var i = 0; i < targets.length; i++) { + item = targets[i]; + + if (jQuery.isArray(item.Target)) { + $target = $(tar(item.Target[0]), tar(item.Target[1])); + } else { + $target = $(tar(item.Target)); + } + + switch (item.Type) { + case 'AddClass': + $target.addClass(item.Data); + break; + case 'Ajax': + $.ajax({ + type: "POST", + url: item.Data + }); + break; + case 'Append': + $target.appendTrigger(item.Data); + break; + case 'Before': + $target.beforeTrigger(item.Data); + break; + case 'After': + $target.afterTrigger(item.Data); + break; + case 'Highlight': + $target.effect("highlight", {}, "slow"); + break; + case 'Prepend': + $target.prependTrigger(item.Data); + break; + case 'Redirect': + window.location.replace(item.Data); + break; + case 'Refresh': + window.location.reload(); + break; + case 'Remove': + $target.remove(); + break; + case 'RemoveClass': + $target.removeClass(item.Data); + break; + case 'ReplaceWith': + $target.replaceWithTrigger(item.Data); + break; + case 'SlideUp': + $target.slideUp('fast'); + break; + case 'SlideDown': + $target.slideDown('fast'); + break; + case 'Text': + $target.text(item.Data); + break; + case 'Trigger': + $target.trigger(item.Data); + break; + case 'Html': + $target.htmlTrigger(item.Data); + break; + case 'Callback': + jQuery.proxy(window[item.Data], $target)(); + break; + } + } + }; + + gdn.requires = function(Library) { + if (!(Library instanceof Array)) + Library = [Library]; + + var Response = true; + + $(Library).each(function(i, Lib) { + // First check if we already have this library + var LibAvailable = gdn.available(Lib); + + if (!LibAvailable) Response = false; + + // Skip any libs that are ready or processing + if (gdn.Libraries[Lib] === false || gdn.Libraries[Lib] === true) + return; + + // As yet unseen. Try to load + gdn.Libraries[Lib] = false; + var Src = '/js/' + Lib + '.js'; + var head = document.getElementsByTagName('head')[0]; + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = Src; + head.appendChild(script); + }); + + if (Response) gdn.loaded(null); + return Response; + }; + + gdn.loaded = function(Library) { + if (Library) + gdn.Libraries[Library] = true; + + $(document).trigger('libraryloaded', [Library]); + }; + + gdn.available = function(Library) { + if (!(Library instanceof Array)) + Library = [Library]; + + for (var i = 0; i < Library.length; i++) { + var Lib = Library[i]; + if (gdn.Libraries[Lib] !== true) return false; + } + return true; + }; + + gdn.url = function(path) { + if (path.indexOf("//") >= 0) + return path; // this is an absolute path. + + var urlFormat = gdn.definition("UrlFormat", "/{Path}"); + + if (path.substr(0, 1) == "/") + path = path.substr(1); + + if (urlFormat.indexOf("?") >= 0) + path = path.replace("?", "&"); + + return urlFormat.replace("{Path}", path); + }; + + // Fill in placeholders. + if (!gdn.elementSupports('input', 'placeholder')) { + $('input:text,textarea').not('.NoIE').each(function() { + var $this = $(this); + var placeholder = $this.attr('placeholder'); + + if (!$this.val() && placeholder) { + $this.val(placeholder); + $this.blur(function() { + if ($this.val() === '') + $this.val(placeholder); + }); + $this.focus(function() { + if ($this.val() == placeholder) + $this.val(''); + }); + $this.closest('form').bind('submit', function() { + if ($this.val() == placeholder) + $this.val(''); + }); + } + }); + } + + $.fn.popin = function(options) { + var settings = $.extend({}, options); + + this.each(function(i, elem) { + var url = $(elem).attr('rel'); + var $elem = $(elem); + $.ajax({ + url: gdn.url(url), + data: {DeliveryType: 'VIEW'}, + success: function(data) { + $elem.html($.parseHTML(data + '')).trigger('contentLoad'); + }, + complete: function() { + $elem.removeClass('Progress TinyProgress InProgress'); + if (settings.complete !== undefined) { + settings.complete($elem); + } + } + }); + }); + }; + $('.Popin, .js-popin').popin(); + + // Make poplist items with a rel attribute clickable. + $(document).on('click', '.PopList .Item[rel]', function() { + window.location.href = $(this).attr('rel'); + }); + + // Add a spinner onclick of buttons with this class + $(document).delegate('input.SpinOnClick', 'click', function() { + $(this).before(' ').removeClass('SpinOnClick'); + }); + + // Confirmation for item removals + $('a.RemoveItem').click(function() { + if (!confirm('Are you sure you would like to remove this item?')) { + return false; + } + }); + + if (window.location.hash === '') { + // Jump to the hash if desired. + if (gdn.definition('LocationHash', 0) !== 0) { + $(window).load(function() { + window.location.hash = gdn.definition('LocationHash'); + }); + } + if (gdn.definition('ScrollTo', 0) !== 0) { + var scrollTo = $(gdn.definition('ScrollTo')); + if (scrollTo.length > 0) { + $('html').animate({ + scrollTop: scrollTo.offset().top - 10 + }); + } + } + } + + gdn.stats = function() { + // Call directly back to the deployment and invoke the stats handler + var StatsURL = gdn.url('settings/analyticstick.json'); + var SendData = { + 'TransientKey': gdn.definition('TransientKey'), + 'Path': gdn.definition('Path'), + 'Args': gdn.definition('Args'), + 'ResolvedPath': gdn.definition('ResolvedPath'), + 'ResolvedArgs': gdn.definition('ResolvedArgs') + }; + + if (gdn.definition('TickExtra', null) !== null) + SendData.TickExtra = gdn.definition('TickExtra'); + + jQuery.ajax({ + dataType: 'json', + type: 'post', + url: StatsURL, + data: SendData, + success: function(json) { + gdn.inform(json); + }, + complete: function(jqXHR, textStatus) { + jQuery(document).triggerHandler('analyticsTick', [SendData, jqXHR, textStatus]); + } + }); + }; + + // Ping back to the deployment server to track views, and trigger + // conditional stats tasks + var AnalyticsTask = gdn.definition('AnalyticsTask', false); + if (AnalyticsTask == 'tick') + gdn.stats(); + + // If a dismissable InformMessage close button is clicked, hide it. + $(document).delegate('div.InformWrapper.Dismissable a.Close, div.InformWrapper .js-inform-close', 'click', function() { + $(this).parents('div.InformWrapper').fadeOut('fast', function() { + $(this).remove(); + }); + }); + + gdn.setAutoDismiss = function() { + var timerId = $('div.InformMessages').attr('autodismisstimerid'); + if (!timerId) { + timerId = setTimeout(function() { + $('div.InformWrapper.AutoDismiss').fadeOut('fast', function() { + $(this).remove(); + }); + $('div.InformMessages').removeAttr('autodismisstimerid'); + }, 7000); + $('div.InformMessages').attr('autodismisstimerid', timerId); + } + }; + + // Handle autodismissals + $(document).on('informMessage', function() { + gdn.setAutoDismiss(); + }); + + // Prevent autodismiss if hovering any inform messages + $(document).delegate('div.InformWrapper', 'mouseover mouseout', function(e) { + if (e.type == 'mouseover') { + var timerId = $('div.InformMessages').attr('autodismisstimerid'); + if (timerId) { + clearTimeout(timerId); + $('div.InformMessages').removeAttr('autodismisstimerid'); + } + } else { + gdn.setAutoDismiss(); + } + }); + + // Take any "inform" messages out of an ajax response and display them on the screen. + gdn.inform = function(response) { + if (!response) + return false; + + if (!response.InformMessages || response.InformMessages.length === 0) + return false; + + // If there is no message container in the page, add one + var informMessages = $('div.InformMessages'); + if (informMessages.length === 0) { + $('
').appendTo('body'); + informMessages = $('div.InformMessages'); + } + var wrappers = $('div.InformMessages div.InformWrapper'), + css, + elementId, + sprite, + dismissCallback, + dismissCallbackUrl; + + // Loop through the inform messages and add them to the container + for (var i = 0; i < response.InformMessages.length; i++) { + css = 'InformWrapper'; + if (response.InformMessages[i].CssClass) + css += ' ' + response.InformMessages[i].CssClass; + + elementId = ''; + if (response.InformMessages[i].id) + elementId = response.InformMessages[i].id; + + sprite = ''; + if (response.InformMessages[i].Sprite) { + css += ' HasSprite'; + sprite = response.InformMessages[i].Sprite; + } + + dismissCallback = response.InformMessages[i].DismissCallback; + dismissCallbackUrl = response.InformMessages[i].DismissCallbackUrl; + if (dismissCallbackUrl) + dismissCallbackUrl = gdn.url(dismissCallbackUrl); + + try { + var message = response.InformMessages[i].Message; + var emptyMessage = message === ''; + + message = '' + message + ''; + + // Is there a sprite? + if (sprite !== '') + message = ''; + + // If the message is dismissable, add a close button + if (css.indexOf('Dismissable') > 0) { + message = '' + message; + } + + message = '
' + message + '
'; + // Insert any transient keys into the message (prevents csrf attacks in follow-on action urls). + message = message.replace(/{TransientKey}/g, gdn.definition('TransientKey')); + if (gdn.getMeta('SelfUrl')) { + // If the url is explicitly defined (as in embed), use it. + message = message.replace(/{SelfUrl}/g, gdn.getMeta('SelfUrl')); + } else { + // Insert the current url as a target for inform anchors + message = message.replace(/{SelfUrl}/g, document.URL); + } + var skip = false; + for (var j = 0; j < wrappers.length; j++) { + if ($(wrappers[j]).text() == $(message).text()) { + skip = true; + } + } + if (!skip) { + if (elementId !== '') { + $('#' + elementId).remove(); + elementId = ' id="' + elementId + '"'; + } + if (!emptyMessage) { + informMessages.prependTrigger('
' + message + '
'); + // Is there a callback or callback url to request on dismiss of the inform message? + if (dismissCallback) { + $('div.InformWrapper:first').find('a.Close').click(eval(dismissCallback)); + } else if (dismissCallbackUrl) { + dismissCallbackUrl = dismissCallbackUrl.replace(/{TransientKey}/g, gdn.definition('TransientKey')); + var closeAnchor = $('div.InformWrapper:first').find('a.Close'); + closeAnchor.attr('callbackurl', dismissCallbackUrl); + closeAnchor.click(function() { + $.ajax({ + type: "POST", + url: $(this).attr('callbackurl'), + data: 'TransientKey=' + gdn.definition('TransientKey'), + dataType: 'json', + error: function(XMLHttpRequest, textStatus, errorThrown) { + gdn.informMessage(XMLHttpRequest.responseText, 'Dismissable AjaxError'); + }, + success: function(json) { + gdn.inform(json); + } + }); + }); + } + } + } + } catch (e) { + } + } + informMessages.show(); + $(document).trigger('informMessage'); + return true; + }; + + // Send an informMessage to the screen (same arguments as controller.InformMessage). + gdn.informMessage = function(message, options) { + if (!options) + options = []; + + if (typeof(options) == 'string') { + var css = options; + options = []; + options.CssClass = css; + } + options.Message = message; + if (!options.CssClass) + options.CssClass = 'Dismissable AutoDismiss'; + + gdn.inform({'InformMessages': new Array(options)}); + }; + + // Inform an error returned from an ajax call. + gdn.informError = function(xhr, silentAbort) { + if (xhr === undefined || xhr === null) + return; + + if (typeof(xhr) == 'string') + xhr = {responseText: xhr, code: 500}; + + var message = xhr.responseText; + var code = xhr.status; + + if (!message) { + switch (xhr.statusText) { + case 'error': + if (silentAbort) + return; + message = 'There was an error performing your request. Please try again.'; + break; + case 'timeout': + message = 'Your request timed out. Please try again.'; + break; + case 'abort': + return; + } + } + + try { + var data = $.parseJSON(message); + if (typeof(data.Exception) == 'string') + message = data.Exception; + } catch (e) { + } + + if (message === '') + message = 'There was an error performing your request. Please try again.'; + + gdn.informMessage('' + message, 'HasSprite Dismissable'); + }; + + // Pick up the inform message stack and display it on page load + var informMessageStack = gdn.definition('InformMessageStack', false); + if (informMessageStack) { + var informMessages; + try { + informMessages = $.parseJSON(informMessageStack); + informMessageStack = {'InformMessages': informMessages}; + gdn.inform(informMessageStack); + } catch (e) { + console.log('informMessageStack contained invalid JSON'); + } + } + + // Ping for new notifications on pageload, and subsequently every 1 minute. + var notificationsPinging = 0, pingCount = 0; + var pingForNotifications = function() { + if (notificationsPinging > 0 || !gdn.focused) + return; + notificationsPinging++; + + $.ajax({ + type: "POST", + url: gdn.url('/notifications/inform'), + data: { + 'TransientKey': gdn.definition('TransientKey'), + 'Path': gdn.definition('Path'), + 'DeliveryMethod': 'JSON', + 'Count': pingCount++ + }, + dataType: 'json', + error: function(XMLHttpRequest, textStatus, errorThrown) { + console.log(XMLHttpRequest.responseText); + }, + success: function(json) { + gdn.inform(json); + }, + complete: function() { + notificationsPinging--; + } + }); + }; + gdn.pingForNotifications = pingForNotifications; + + if (gdn.definition('SignedIn', '0') != '0' && gdn.definition('DoInform', '1') != '0') { + setTimeout(pingForNotifications, 3000); + setInterval(pingForNotifications, 60000); + } + + // Clear notifications alerts when they are accessed anywhere. + $(document).on('click', '.js-clear-notifications', function() { + $('.NotificationsAlert').remove(); + }); + + $(document).on('change', '.js-nav-dropdown', function() { + window.location = $(this).val(); + }); + + // Stash something in the user's session (or unstash the value if it was not provided) + var stash = function(name, value, callback) { + $.ajax({ + type: "POST", + url: gdn.url('session/stash'), + data: {'TransientKey': gdn.definition('TransientKey'), 'Name': name, 'Value': value}, + dataType: 'json', + error: function(XMLHttpRequest, textStatus, errorThrown) { + gdn.informMessage(XMLHttpRequest.responseText, 'Dismissable AjaxError'); + }, + success: function(json) { + gdn.inform(json); + + if (typeof(callback) === 'function') { + callback(); + } else { + return json.Unstash; + } + } + }); + + return ''; + }; + + // When a stash anchor is clicked, look for inputs with values to stash + $('a.Stash').click(function(e) { + var comment = $('#Form_Comment textarea').val(), + placeholder = $('#Form_Comment textarea').attr('placeholder'), + stash_name; + + // Stash a comment: + if (comment !== '' && comment !== placeholder) { + var vanilla_identifier = gdn.definition('vanilla_identifier', false); + + if (vanilla_identifier) { + // Embedded comment: + stash_name = 'CommentForForeignID_' + vanilla_identifier; + } else { + // Non-embedded comment: + stash_name = 'CommentForDiscussionID_' + gdn.definition('DiscussionID'); + } + var href = $(this).attr('href'); + e.preventDefault(); + + stash(stash_name, comment, function() { + window.top.location = href; + }); + } + }); + + String.prototype.addCommas = function() { + var nStr = this, + x = nStr.split('.'), + x1 = x[0], + x2 = x.length > 1 ? '.' + x[1] : '', + rgx = /(\d+)(\d{3})/; + while (rgx.test(x1)) { + x1 = x1.replace(rgx, '$1' + ',' + '$2'); + } + return x1 + x2; + }; + + Array.prototype.sum = function() { + for (var i = 0, sum = 0; i < this.length; sum += this[i++]); + return sum; + }; + + Array.prototype.max = function() { + return Math.max.apply({}, this); + }; + + Array.prototype.min = function() { + return Math.min.apply({}, this); + }; + + if (/msie/.test(navigator.userAgent.toLowerCase())) { + $('body').addClass('MSIE'); + } + + var d = new Date(); + var hourOffset = -Math.round(d.getTimezoneOffset() / 60); + var tz = false; + + /** + * ECMAScript Internationalization API is supported by all modern browsers, with the exception of Safari. We use + * it here, with lots of careful checking, to attempt to fetch the user's current IANA time zone string. + */ + if (typeof Intl === 'object' && typeof Intl.DateTimeFormat === 'function') { + var dateTimeFormat = Intl.DateTimeFormat(); + if (typeof dateTimeFormat.resolvedOptions === 'function') { + var resolvedOptions = dateTimeFormat.resolvedOptions(); + if (typeof resolvedOptions === 'object' && typeof resolvedOptions.timeZone === 'string') { + tz = resolvedOptions.timeZone; + } + } + } + + // Ajax/Save the ClientHour if it is different from the value in the db. + var setHourOffset = parseInt(gdn.definition('SetHourOffset', hourOffset)); + var setTimeZone = gdn.definition('SetTimeZone', tz); + if (hourOffset !== setHourOffset || (tz && tz !== setTimeZone)) { + $.post( + gdn.url('/utility/sethouroffset.json'), + {HourOffset: hourOffset, TimeZone: tz, TransientKey: gdn.definition('TransientKey')} + ); + } + + // Add "checked" class to item rows if checkboxes are checked within. + var checkItems = function() { + var container = $(this).parents('.Item'); + if ($(this).prop('checked')) + $(container).addClass('Checked'); + else + $(container).removeClass('Checked'); + }; + $('.Item :checkbox').each(checkItems); + $('.Item :checkbox').change(checkItems); + + // If we are not inside an iframe, focus the email input on the signin page. + if ($('#Form_User_SignIn').length && window.top.location === window.location) { + $('#Form_Email').focus(); + } + + // Convert date fields to datepickers + if ($.fn.datepicker) { + $('input.DatePicker').datepicker({ + showOn: "focus", + dateFormat: 'mm/dd/yy' + }); + } + + /** + * Youtube preview revealing + * + */ + + // Reveal youtube player when preview clicked. + function Youtube($container) { + var $preview = $container.find('.VideoPreview'); + var $player = $container.find('.VideoPlayer'); + + $container.addClass('Open').closest('.ImgExt').addClass('Open'); + + var width = $preview.width(), height = $preview.height(), videoid = ''; + + try { + videoid = $container.attr('data-youtube').replace('youtube-', ''); + } catch (e) { + console.log("YouTube parser found invalid id attribute: " + videoid); + } + + + // Verify we have a valid videoid + var pattern = /^[\w-]+(\?autoplay\=1)(\&start=[\w-]+)?(\&rel=.)?$/; + if (!pattern.test(videoid)) { + return false; + } + + var html = ''; + $player.html(html); + + $preview.hide(); + $player.show(); + + return false; + } + + $(document).delegate('.Video.YouTube .VideoPreview', 'click', function(e) { + var $target = $(e.target); + var $container = $target.closest('.Video.YouTube'); + return Youtube($container); + }); + + /** + * Pintrest pin embedding + * + */ + + if ($('a.pintrest-pin').length) { + (function(d) { + var f = d.getElementsByTagName('SCRIPT')[0], p = d.createElement('SCRIPT'); + p.type = 'text/javascript'; + p.async = true; + p.src = '//assets.pinterest.com/js/pinit.js'; + f.parentNode.insertBefore(p, f); + }(document)); + } + + /** + * Textarea autosize. + * + * Create wrapper for autosize library, so that the custom + * arguments passed do not need to be repeated for every call, if for some + * reason it needs to be binded elsewhere and the UX should be identical, + * otherwise just use autosize directly, passing arguments or none. + * + * Note: there should be no other calls to autosize, except for in this file. + * All previous calls to the old jquery.autogrow were called in their + * own files, which made managing this functionality less than optimal. Now + * all textareas will have autosize binded to them by default. + * + * @depends js/library/jquery.autosize.min.js + */ + gdn.autosize = function(textarea) { + // Check if library available. + if ($.fn.autosize) { + // Check if not already active on node. + if (!$(textarea).hasClass('textarea-autosize')) { + $(textarea).autosize({ + append: '\n', + resizeDelay: 20, // Keep higher than CSS transition, else creep. + callback: function(el) { + // This class adds the transition, and removes manual resize. + $(el).addClass('textarea-autosize'); + } + }); + // Otherwise just trigger a resize refresh. + } else { + $(textarea).trigger('autosize.resize'); + } + } + }; + + /** + * Bind autosize to relevant nodes. + * + * Attach autosize to all textareas. Previously this existed across multiple + * files, probably as it was slowly incorporated into different areas, but + * at this point it's safe to call it once here. The wrapper above makes + * sure that it will not throw any errors if the library is unavailable. + * + * Note: if there is a textarea not autosizing, it would be good to find out + * if there is another event for that exception, and if all fails, there + * is the livequery fallback, which is not recommended. + */ + gdn.initAutosizeEvents = (function() { + $('textarea').each(function(i, el) { + // Attach to all immediately available textareas. + gdn.autosize(el); + + // Also, make sure that focus on the textarea will trigger a resize, + // just to cover all possibilities. + $(el).on('focus', function(e) { + gdn.autosize(this); + }); + }); + + // For any dynamically loaded textareas that are inserted and have an + // event triggered to grab their node, or just events that should call + // a resize on the textarea. Attempted to bind to `appendHtml` event, + // but it required a (0ms) timeout, so it's being kept in Quotes plugin, + // where it's actually triggered. + var autosizeTriggers = [ + 'clearCommentForm', + 'popupReveal', + 'contentLoad' + ]; + + $(document).on(autosizeTriggers.join(' '), function(e, data) { + data = (typeof data == 'object') ? data : ''; + $(data || e.target || this).parent().find('textarea').each(function(i, el) { + gdn.autosize(el); + }); + }); + }()); + + // http://stackoverflow.com/questions/118241/calculate-text-width-with-javascript + String.prototype.width = function(font) { + var f = font || "15px 'lucida grande','Lucida Sans Unicode',tahoma,sans-serif'", + o = $('
' + this + '
') + .css({ + 'position': 'absolute', + 'float': 'left', + 'white-space': 'nowrap', + 'visibility': 'hidden', + 'font': f + }) + .appendTo($('body')), + w = o.width(); + o.remove(); + return w; + }; + + /** + * Running magnific-popup. Image tag or text must be wrapped with an anchor + * tag. This will render the content of the anchor tag's href. If using an + * image tag, the anchor tag's href can point to either the same location + * as the image tag, or a higher quality version of the image. If zoom is + * not wanted, remove the zoom and mainClass properties, and it will just + * load the content of the anchor tag with no special effects. + * + * @documentation http://dimsemenov.com/plugins/magnific-popup/documentation.html + * + */ + gdn.magnificPopup = (function() { + if ($.fn.magnificPopup) { + $('.mfp-image').each(function(i, el) { + $(el).magnificPopup({ + type: 'image', + mainClass: 'mfp-with-zoom', + zoom: { + enabled: true, + duration: 300, + easing: 'ease', + opener: function(openerElement) { + return openerElement.is('img') + ? openerElement + : openerElement.find('img'); + } + } + }); + }); + } + }()); + + /** + * A kludge to dodge Safari's back-forward cache (bfcache). Without this, Safari maintains + * the a page's DOM during back/forward navigation and hinders our ability to invalidate + * the cached state of content. + */ + if (/Apple Computer/.test(navigator.vendor) && /Safari/.test(navigator.userAgent)) { + jQuery(window).on("pageshow", function(event) { + if (event.originalEvent.persisted) { + window.location.reload(); + } + }); + } + + $(document).trigger('contentLoad'); +}); + +// Shrink large images to fit into message space, and pop into new window when clicked. +// This needs to happen in onload because otherwise the image sizes are not yet known. +jQuery(window).load(function() { + /* + Adds .naturalWidth() and .naturalHeight() methods to jQuery for retreaving a + normalized naturalWidth and naturalHeight. + // Example usage: + var + nWidth = $('img#example').naturalWidth(), + nHeight = $('img#example').naturalHeight(); + */ + + (function($) { + var props = ['Width', 'Height'], + prop; + + while (prop = props.pop()) { + (function(natural, prop) { + $.fn[natural] = (natural in new Image()) ? + function() { + return this[0][natural]; + } : + function() { + var node = this[0], + img, + value; + + if (node.tagName.toLowerCase() === 'img') { + img = new Image(); + img.src = node.src; + value = img[prop]; + } + return value; + }; + }('natural' + prop, prop.toLowerCase())); + } + }(jQuery)); + + jQuery('div.Message img') + .not(jQuery('div.Message a > img')) + .not(jQuery('.js-embed img')) + .not(jQuery('.embedImage-img')) + .each(function (i, img){ + img = jQuery(img); + var container = img.closest('div.Message'); + if (img.naturalWidth() > container.width() && container.width() > 0) { + img.wrap(''); + } + }); + + // Let the world know we're done here + jQuery(window).trigger('ImagesResized'); +}); + +if (typeof String.prototype.trim !== 'function') { + String.prototype.trim = function() { + return this.replace(/^\s+|\s+$/g, ''); + }; +} + +(function ($) { + $.fn.extend({ + // jQuery UI .effect() replacement using CSS classes. + effect: function(name) { + var that = this; + name = name + '-effect'; + + return this + .addClass(name) + .one('animationend webkitAnimationEnd', function () { + that.removeClass(name); + }); + }, +}); + +})(jQuery);