X-Git-Url: https://git.mxchange.org/?a=blobdiff_plain;f=js%2Futil.js;h=1989e92c099ea050c7edcf3e50e1f22c8297eff1;hb=5358f78e824412919ddd8f3d7d2b0bf68a6292d4;hp=ad8a44c82ad5d9fa76fe47197bb30796d45a685f;hpb=a68c10280fd66f1a6e8d7a776bacfcf38907afa6;p=quix0rs-gnu-social.git diff --git a/js/util.js b/js/util.js index ad8a44c82a..1bd011cb0d 100644 --- a/js/util.js +++ b/js/util.js @@ -19,7 +19,8 @@ * @package StatusNet * @author Sarven Capadisli * @author Evan Prodromou - * @copyright 2009 StatusNet, Inc. + * @author Brion Vibber + * @copyright 2009,2010 StatusNet, Inc. * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ @@ -33,6 +34,14 @@ var SN = { // StatusNet HTTP20x30x: [200, 201, 202, 203, 204, 205, 206, 300, 301, 302, 303, 304, 305, 306, 307] }, + /** + * @fixme are these worth the trouble? They seem to mostly just duplicate + * themselves while slightly obscuring the actual selector, so it's hard + * to pop over to the HTML and find something. + * + * In theory, minification could reduce them to shorter variable names, + * but at present that doesn't happen with yui-compressor. + */ S: { // Selector Disabled: 'disabled', Warning: 'warning', @@ -41,17 +50,6 @@ var SN = { // StatusNet Processing: 'processing', CommandResult: 'command_result', FormNotice: 'form_notice', - NoticeDataText: 'notice_data-text', - NoticeTextCount: 'notice_text-count', - NoticeInReplyTo: 'notice_in-reply-to', - NoticeDataAttach: 'notice_data-attach', - NoticeDataAttachSelected: 'notice_data-attach_selected', - NoticeActionSubmit: 'notice_action-submit', - NoticeLat: 'notice_data-lat', - NoticeLon: 'notice_data-lon', - NoticeLocationId: 'notice_data-location_id', - NoticeLocationNs: 'notice_data-location_ns', - NoticeGeoName: 'notice_data-geo_name', NoticeDataGeo: 'notice_data-geo', NoticeDataGeoCookie: 'NoticeDataGeo', NoticeDataGeoSelected: 'notice_data-geo_selected', @@ -59,10 +57,45 @@ var SN = { // StatusNet } }, + /** + * Map of localized message strings exported to script from the PHP + * side via Action::getScriptMessages(). + * + * Retrieve them via SN.msg(); this array is an implementation detail. + * + * @access private + */ + messages: {}, + + /** + * Grabs a localized string that's been previously exported to us + * from server-side code via Action::getScriptMessages(). + * + * @example alert(SN.msg('coolplugin-failed')); + * + * @param {String} key: string key name to pull from message index + * @return matching localized message string + */ + msg: function(key) { + if (typeof SN.messages[key] == "undefined") { + return '[' + key + ']'; + } else { + return SN.messages[key]; + } + }, + U: { // Utils + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up event handlers on the new notice form. + * + * @param {jQuery} form: jQuery object whose first matching element is the form + * @access private + */ FormNoticeEnhancements: function(form) { if (jQuery.data(form[0], 'ElementData') === undefined) { - MaxLength = form.find('#'+SN.C.S.NoticeTextCount).text(); + MaxLength = form.find('.count').text(); if (typeof(MaxLength) == 'undefined') { MaxLength = SN.C.I.MaxLength; } @@ -70,37 +103,44 @@ var SN = { // StatusNet SN.U.Counter(form); - NDT = form.find('#'+SN.C.S.NoticeDataText); + NDT = form.find('[name=status_textarea]'); NDT.bind('keyup', function(e) { SN.U.Counter(form); }); - NDT.bind('keydown', function(e) { - SN.U.SubmitOnReturn(e, form); - }); + var delayedUpdate= function(e) { + // Cut and paste events fire *before* the operation, + // so we need to trigger an update in a little bit. + // This would be so much easier if the 'change' event + // actually fired every time the value changed. :P + window.setTimeout(function() { + SN.U.Counter(form); + }, 50); + }; + // Note there's still no event for mouse-triggered 'delete'. + NDT.bind('cut', delayedUpdate) + .bind('paste', delayedUpdate); } else { - form.find('#'+SN.C.S.NoticeTextCount).text(jQuery.data(form[0], 'ElementData').MaxLength); - } - - if ($('body')[0].id != 'conversation' && window.location.hash.length === 0 && $(window).scrollTop() == 0) { - form.find('textarea').focus(); + form.find('.count').text(jQuery.data(form[0], 'ElementData').MaxLength); } }, - SubmitOnReturn: function(event, el) { - if (event.keyCode == 13 || event.keyCode == 10) { - el.submit(); - event.preventDefault(); - event.stopPropagation(); - $('#'+el[0].id+' #'+SN.C.S.NoticeDataText).blur(); - $('body').focus(); - return false; - } - return true; - }, - + /** + * To be called from event handlers on the notice import form. + * Triggers an update of the remaining-characters counter. + * + * Additional counter updates will be suppressed during the + * next half-second to avoid flooding the layout engine with + * updates, followed by another automatic check. + * + * The maximum length is pulled from data established by + * FormNoticeEnhancements. + * + * @param {jQuery} form: jQuery object whose first element is the notice posting form + * @access private + */ Counter: function(form) { SN.C.I.FormNoticeCurrent = form; @@ -111,7 +151,7 @@ var SN = { // StatusNet } var remaining = MaxLength - SN.U.CharacterCount(form); - var counter = form.find('#'+SN.C.S.NoticeTextCount); + var counter = form.find('.count'); if (remaining.toString() != counter.text()) { if (!SN.C.I.CounterBlackout || remaining === 0) { @@ -134,10 +174,24 @@ var SN = { // StatusNet } }, + /** + * Pull the count of characters in the current edit field. + * Plugins replacing the edit control may need to override this. + * + * @param {jQuery} form: jQuery object whose first element is the notice posting form + * @return number of chars + */ CharacterCount: function(form) { - return form.find('#'+SN.C.S.NoticeDataText).val().length; + return form.find('[name=status_textarea]').val().length; }, + /** + * Called internally after the counter update blackout period expires; + * runs another update to make sure we didn't miss anything. + * + * @param {jQuery} form: jQuery object whose first element is the notice posting form + * @access private + */ ClearCounterBlackout: function(form) { // Allow keyup events to poke the counter again SN.C.I.CounterBlackout = false; @@ -145,11 +199,47 @@ var SN = { // StatusNet SN.U.Counter(form); }, + /** + * Helper function to rewrite default HTTP form action URLs to HTTPS + * so we can actually fetch them when on an SSL page in ssl=sometimes + * mode. + * + * It would be better to output URLs that didn't hardcode protocol + * and hostname in the first place... + * + * @param {String} url + * @return string + */ + RewriteAjaxAction: function(url) { + // Quick hack: rewrite AJAX submits to HTTPS if they'd fail otherwise. + if (document.location.protocol == 'https:' && url.substr(0, 5) == 'http:') { + return url.replace(/^http:\/\/[^:\/]+/, 'https://' + document.location.host); + } else { + return url; + } + }, + + /** + * Grabs form data and submits it asynchronously, with 'ajax=1' + * parameter added to the rest. + * + * If a successful response includes another form, that form + * will be extracted and copied in, replacing the original form. + * If there's no form, the first paragraph will be used. + * + * @fixme can sometimes explode confusingly if returnd data is bogus + * @fixme error handling is pretty vague + * @fixme can't submit file uploads + * + * @param {jQuery} form: jQuery object whose first element is a form + * + * @access public + */ FormXHR: function(form) { $.ajax({ type: 'POST', dataType: 'xml', - url: form.attr('action'), + url: SN.U.RewriteAjaxAction(form.attr('action')), data: form.serialize() + '&ajax=1', beforeSend: function(xhr) { form @@ -173,101 +263,133 @@ var SN = { // StatusNet }); }, + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up event handlers for special-cased async submission of the + * notice-posting form, including some pre-post validation. + * + * Unlike FormXHR() this does NOT submit the form immediately! + * It sets up event handlers so that any method of submitting the + * form (click on submit button, enter, submit() etc) will trigger + * it properly. + * + * Also unlike FormXHR(), this system will use a hidden iframe + * automatically to handle file uploads via + * controls. + * + * @fixme tl;dr + * @fixme vast swaths of duplicate code and really long variable names clutter this function up real bad + * @fixme error handling is unreliable + * @fixme cookieValue is a global variable, but probably shouldn't be + * @fixme saving the location cache cookies should be split out + * @fixme some error messages are hardcoded english: needs i18n + * @fixme special-case for bookmarklet is confusing and uses a global var "self". Is this ok? + * + * @param {jQuery} form: jQuery object whose first element is a form + * + * @access public + */ FormNoticeXHR: function(form) { SN.C.I.NoticeDataGeo = {}; form.append(''); + + // Make sure we don't have a mixed HTTP/HTTPS submission... + form.attr('action', SN.U.RewriteAjaxAction(form.attr('action'))); + + /** + * Show a response feedback bit under the new-notice dialog. + * + * @param {String} cls: CSS class name to use ('error' or 'success') + * @param {String} text + * @access private + */ + var showFeedback = function(cls, text) { + form.append( + $('

') + .addClass(cls) + .text(text) + ); + }; + + /** + * Hide the previous response feedback, if any. + */ + var removeFeedback = function() { + form.find('.form_response').remove(); + }; + form.ajaxForm({ dataType: 'xml', timeout: '60000', beforeSend: function(formData) { - if (form.find('#'+SN.C.S.NoticeDataText)[0].value.length === 0) { + if (form.find('[name=status_textarea]').val() == '') { form.addClass(SN.C.S.Warning); return false; } form .addClass(SN.C.S.Processing) - .find('#'+SN.C.S.NoticeActionSubmit) + .find('.submit') .addClass(SN.C.S.Disabled) .attr(SN.C.S.Disabled, SN.C.S.Disabled); - SN.C.I.NoticeDataGeo.NLat = $('#'+SN.C.S.NoticeLat).val(); - SN.C.I.NoticeDataGeo.NLon = $('#'+SN.C.S.NoticeLon).val(); - SN.C.I.NoticeDataGeo.NLNS = $('#'+SN.C.S.NoticeLocationNs).val(); - SN.C.I.NoticeDataGeo.NLID = $('#'+SN.C.S.NoticeLocationId).val(); - SN.C.I.NoticeDataGeo.NDG = $('#'+SN.C.S.NoticeDataGeo).attr('checked'); - - cookieValue = $.cookie(SN.C.S.NoticeDataGeoCookie); - - if (cookieValue !== null && cookieValue != 'disabled') { - cookieValue = JSON.parse(cookieValue); - SN.C.I.NoticeDataGeo.NLat = $('#'+SN.C.S.NoticeLat).val(cookieValue.NLat).val(); - SN.C.I.NoticeDataGeo.NLon = $('#'+SN.C.S.NoticeLon).val(cookieValue.NLon).val(); - if ($('#'+SN.C.S.NoticeLocationNs).val(cookieValue.NLNS)) { - SN.C.I.NoticeDataGeo.NLNS = $('#'+SN.C.S.NoticeLocationNs).val(cookieValue.NLNS).val(); - SN.C.I.NoticeDataGeo.NLID = $('#'+SN.C.S.NoticeLocationId).val(cookieValue.NLID).val(); - } - } - if (cookieValue == 'disabled') { - SN.C.I.NoticeDataGeo.NDG = $('#'+SN.C.S.NoticeDataGeo).attr('checked', false).attr('checked'); - } - else { - SN.C.I.NoticeDataGeo.NDG = $('#'+SN.C.S.NoticeDataGeo).attr('checked', true).attr('checked'); - } + SN.U.normalizeGeoData(form); return true; }, error: function (xhr, textStatus, errorThrown) { form .removeClass(SN.C.S.Processing) - .find('#'+SN.C.S.NoticeActionSubmit) + .find('.submit') .removeClass(SN.C.S.Disabled) .removeAttr(SN.C.S.Disabled, SN.C.S.Disabled); - form.find('.form_response').remove(); + removeFeedback(); if (textStatus == 'timeout') { - form.append('

Sorry! We had trouble sending your notice. The servers are overloaded. Please try again, and contact the site administrator if this problem persists.

'); + // @fixme i18n + showFeedback('error', 'Sorry! We had trouble sending your notice. The servers are overloaded. Please try again, and contact the site administrator if this problem persists.'); } else { - if ($('.'+SN.C.S.Error, xhr.responseXML).length > 0) { - form.append(document._importNode($('.'+SN.C.S.Error, xhr.responseXML)[0], true)); + var response = SN.U.GetResponseXML(xhr); + if ($('.'+SN.C.S.Error, response).length > 0) { + form.append(document._importNode($('.'+SN.C.S.Error, response)[0], true)); } else { if (parseInt(xhr.status) === 0 || jQuery.inArray(parseInt(xhr.status), SN.C.I.HTTP20x30x) >= 0) { form .resetForm() - .find('#'+SN.C.S.NoticeDataAttachSelected).remove(); + .find('.attach-status').remove(); SN.U.FormNoticeEnhancements(form); } else { - form.append('

(Sorry! We had trouble sending your notice ('+xhr.status+' '+xhr.statusText+'). Please report the problem to the site administrator if this happens again.

'); + // @fixme i18n + showFeedback('error', '(Sorry! We had trouble sending your notice ('+xhr.status+' '+xhr.statusText+'). Please report the problem to the site administrator if this happens again.'); } } } }, success: function(data, textStatus) { - form.find('.form_response').remove(); - var result; - if ($('#'+SN.C.S.Error, data).length > 0) { - result = document._importNode($('p', data)[0], true); - result = result.textContent || result.innerHTML; - form.append('

'+result+'

'); + removeFeedback(); + var errorResult = $('#'+SN.C.S.Error, data); + if (errorResult.length > 0) { + showFeedback('error', errorResult.text()); } else { if($('body')[0].id == 'bookmarklet') { + // @fixme self is not referenced anywhere? self.close(); } - if ($('#'+SN.C.S.CommandResult, data).length > 0) { - result = document._importNode($('p', data)[0], true); - result = result.textContent || result.innerHTML; - form.append('

'+result+'

'); + var commandResult = $('#'+SN.C.S.CommandResult, data); + if (commandResult.length > 0) { + showFeedback('success', commandResult.text()); } else { // New notice post was successful. If on our timeline, show it! var notice = document._importNode($('li', data)[0], true); - var notices = $('#notices_primary .notices'); + var notices = $('#notices_primary .notices:first'); if (notices.length > 0 && SN.U.belongsOnTimeline(notice)) { if ($('#'+notice.id).length === 0) { - var notice_irt_value = $('#'+SN.C.S.NoticeInReplyTo).val(); + var notice_irt_value = form.find('[name=inreplyto]').val(); var notice_irt = '#notices_primary #notice-'+notice_irt_value; if($('body')[0].id == 'conversation') { if(notice_irt_value.length > 0 && $(notice_irt+' .notices').length < 1) { @@ -288,71 +410,316 @@ var SN = { // StatusNet else { // Not on a timeline that this belongs on? // Just show a success message. - result = document._importNode($('title', data)[0], true); - result_title = result.textContent || result.innerHTML; - form.append('

'+result_title+'

'); + showFeedback('success', $('title', data).text()); } } form.resetForm(); - form.find('#'+SN.C.S.NoticeInReplyTo).val(''); - form.find('#'+SN.C.S.NoticeDataAttachSelected).remove(); + form.find('[name=inreplyto]').val(''); + form.find('.attach-status').remove(); SN.U.FormNoticeEnhancements(form); } }, complete: function(xhr, textStatus) { form .removeClass(SN.C.S.Processing) - .find('#'+SN.C.S.NoticeActionSubmit) + .find('.submit') .removeAttr(SN.C.S.Disabled) .removeClass(SN.C.S.Disabled); - $('#'+SN.C.S.NoticeLat).val(SN.C.I.NoticeDataGeo.NLat); - $('#'+SN.C.S.NoticeLon).val(SN.C.I.NoticeDataGeo.NLon); - if ($('#'+SN.C.S.NoticeLocationNs)) { - $('#'+SN.C.S.NoticeLocationNs).val(SN.C.I.NoticeDataGeo.NLNS); - $('#'+SN.C.S.NoticeLocationId).val(SN.C.I.NoticeDataGeo.NLID); - } - $('#'+SN.C.S.NoticeDataGeo).attr('checked', SN.C.I.NoticeDataGeo.NDG); + form.find('[name=lat]').val(SN.C.I.NoticeDataGeo.NLat); + form.find('[name=lon]').val(SN.C.I.NoticeDataGeo.NLon); + form.find('[name=location_ns]').val(SN.C.I.NoticeDataGeo.NLNS); + form.find('[name=location_id]').val(SN.C.I.NoticeDataGeo.NLID); + form.find('[name=notice_data-geo]').attr('checked', SN.C.I.NoticeDataGeo.NDG); } }); }, + normalizeGeoData: function(form) { + SN.C.I.NoticeDataGeo.NLat = form.find('[name=lat]').val(); + SN.C.I.NoticeDataGeo.NLon = form.find('[name=lon]').val(); + SN.C.I.NoticeDataGeo.NLNS = form.find('[name=location_ns]').val(); + SN.C.I.NoticeDataGeo.NLID = form.find('[name=location_id]').val(); + SN.C.I.NoticeDataGeo.NDG = form.find('[name=notice_data-geo]').attr('checked'); // @fixme + + var cookieValue = $.cookie(SN.C.S.NoticeDataGeoCookie); + + if (cookieValue !== null && cookieValue != 'disabled') { + cookieValue = JSON.parse(cookieValue); + SN.C.I.NoticeDataGeo.NLat = form.find('[name=lat]').val(cookieValue.NLat).val(); + SN.C.I.NoticeDataGeo.NLon = form.find('[name=lon]').val(cookieValue.NLon).val(); + if (cookieValue.NLNS) { + SN.C.I.NoticeDataGeo.NLNS = form.find('[name=location_ns]').val(cookieValue.NLNS).val(); + SN.C.I.NoticeDataGeo.NLID = form.find('[name=location_id]').val(cookieValue.NLID).val(); + } else { + form.find('[name=location_ns]').val(''); + form.find('[name=location_id]').val(''); + } + } + if (cookieValue == 'disabled') { + SN.C.I.NoticeDataGeo.NDG = form.find('[name=notice_data-geo]').attr('checked', false).attr('checked'); + } + else { + SN.C.I.NoticeDataGeo.NDG = form.find('[name=notice_data-geo]').attr('checked', true).attr('checked'); + } + + }, + /** + * Fetch an XML DOM from an XHR's response data. + * + * Works around unavailable responseXML when document.domain + * has been modified by Meteor or other tools, in some but not + * all browsers. + * + * @param {XMLHTTPRequest} xhr + * @return DOMDocument + */ + GetResponseXML: function(xhr) { + try { + return xhr.responseXML; + } catch (e) { + return (new DOMParser()).parseFromString(xhr.responseText, "text/xml"); + } + }, + + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up event handlers on all visible notice's reply buttons to + * tweak the new-notice form with needed variables and focus it + * when pushed. + * + * (This replaces the default reply button behavior to submit + * directly to a form which comes back with a specialized page + * with the form data prefilled.) + * + * @access private + */ NoticeReply: function() { - if ($('#'+SN.C.S.NoticeDataText).length > 0 && $('#content .notice_reply').length > 0) { + if ($('#content .notice_reply').length > 0) { $('#content .notice').each(function() { SN.U.NoticeReplyTo($(this)); }); } }, + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up event handlers on the given notice's reply button to + * tweak the new-notice form with needed variables and focus it + * when pushed. + * + * (This replaces the default reply button behavior to submit + * directly to a form which comes back with a specialized page + * with the form data prefilled.) + * + * @param {jQuery} notice: jQuery object containing one or more notices + * @access private + */ NoticeReplyTo: function(notice) { - notice.find('.notice_reply').live('click', function() { + notice.find('.notice_reply').live('click', function(e) { + e.preventDefault(); var nickname = ($('.author .nickname', notice).length > 0) ? $($('.author .nickname', notice)[0]) : $('.author .nickname.uid'); - SN.U.NoticeReplySet(nickname.text(), $($('.notice_id', notice)[0]).text()); + SN.U.NoticeInlineReplyTrigger(notice, '@' + nickname.text()); return false; }); }, - NoticeReplySet: function(nick,id) { - if (nick.match(SN.C.I.PatternUsername)) { - var text = $('#'+SN.C.S.NoticeDataText); - if (text.length > 0) { - replyto = '@' + nick + ' '; - text.val(replyto + text.val().replace(RegExp(replyto, 'i'), '')); - $('#'+SN.C.S.FormNotice+' #'+SN.C.S.NoticeInReplyTo).val(id); + /** + * Open up a notice's inline reply box. + * + * @param {jQuery} notice: jQuery object containing one notice + * @param {String} initialText + */ + NoticeInlineReplyTrigger: function(notice, initialText) { + // Find the notice we're replying to... + var id = $($('.notice_id', notice)[0]).text(); + var parentNotice = notice; + + // Find the threaded replies view we'll be adding to... + var list = notice.closest('.notices'); + if (list.hasClass('threaded-replies')) { + // We're replying to a reply; use reply form on the end of this list. + // We'll add our form at the end of this; grab the root notice. + parentNotice = list.closest('.notice'); + } else { + // We're replying to a parent notice; pull its threaded list + // and we'll add on the end of it. Will add if needed. + list = $('ul.threaded-replies', notice); + if (list.length == 0) { + list = $('
    '); + notice.append(list); + } + } - text[0].focus(); - if (text[0].setSelectionRange) { - var len = text.val().length; - text[0].setSelectionRange(len,len); - } + // See if the form's already open... + var replyForm = $('.notice-reply-form', list); + + var nextStep = function() { + // Override...? + replyForm.find('input[name=inreplyto]').val(id); + + // Set focus... + var text = replyForm.find('textarea'); + if (text.length == 0) { + throw "No textarea"; + } + var replyto = ''; + if (initialText) { + replyto = initialText + ' '; + } + text.val(replyto + text.val().replace(RegExp(replyto, 'i'), '')); + text.data('initialText', $.trim(initialText + '')); + text.focus(); + if (text[0].setSelectionRange) { + var len = text.val().length; + text[0].setSelectionRange(len,len); + } + }; + if (replyForm.length > 0) { + // Update the existing form... + nextStep(); + } else { + // Remove placeholder if any + $('li.notice-reply-placeholder').remove(); + + // Create the reply form entry at the end + var replyItem = $('li.notice-reply', list); + if (replyItem.length == 0) { + var url = $('#form_notice').attr('action'); + replyItem = $('
  • '); + $.get(url, {ajax: 1}, function(data, textStatus, xhr) { + var formEl = document._importNode($('form', data)[0], true); + replyItem.append(formEl); + list.append(replyItem); + + var form = replyForm = $(formEl); + SN.U.NoticeLocationAttach(form); + SN.U.FormNoticeXHR(form); + SN.U.FormNoticeEnhancements(form); + SN.U.NoticeDataAttach(form); + + nextStep(); + }); + /* + replyItem = $('
  • ' + + '
    ' + + '' + + '
    ' + + '' + + '' + + '' + + '
    ' + + '
    ' + + '
  • '); + var baseForm = $('#form_notice'); + replyForm = replyItem.find('form'); + replyForm.attr('action', baseForm.attr('action')); + replyForm.find('input[name="token"]').val(baseForm.find('input[name=token]').val()); + replyForm.find('input[type="submit"]').val(SN.msg('reply_submit')); + list.append(replyItem); + + replyForm.find('textarea').blur(function() { + var textarea = $(this); + var txt = $.trim(textarea.val()); + if (txt == '' || txt == textarea.data('initialText')) { + // Nothing to say? Begone! + replyItem.remove(); + if (list.find('li').length > 0) { + SN.U.NoticeInlineReplyPlaceholder(parentNotice); + } else { + list.remove(); + } + } + }); + replyForm.submit(function(event) { + var form = replyForm; + $.ajax({ + type: 'POST', + dataType: 'xml', + url: SN.U.RewriteAjaxAction(form.attr('action')), + data: form.serialize() + '&ajax=1', + beforeSend: function(xhr) { + form + .addClass(SN.C.S.Processing) + .find('.submit') + .addClass(SN.C.S.Disabled) + .attr(SN.C.S.Disabled, SN.C.S.Disabled) + .end() + .find('textarea') + .addClass(SN.C.S.Disabled) + .attr(SN.C.S.Disabled, SN.C.S.Disabled); + }, + error: function (xhr, textStatus, errorThrown) { + alert(errorThrown || textStatus); + }, + success: function(data, textStatus) { + var orig_li = $('li', data)[0]; + if (orig_li) { + var li = document._importNode(orig_li, true); + var id = $(li).attr('id'); + if ($("#"+id).length == 0) { + replyItem.replaceWith(li); + SN.U.NoticeInlineReplyPlaceholder(parentNotice); + } else { + // Realtime came through before us... + replyItem.remove(); + } + } + } + }); + event.preventDefault(); + return false; + }); + */ } } }, + /** + * Setup function -- DOES NOT apply immediately. + * + * Sets up event handlers for favor/disfavor forms to submit via XHR. + * Uses 'live' rather than 'bind', so applies to future as well as present items. + */ NoticeFavor: function() { $('.form_favor').live('click', function() { SN.U.FormXHR($(this)); return false; }); $('.form_disfavor').live('click', function() { SN.U.FormXHR($(this)); return false; }); }, + NoticeInlineReplyPlaceholder: function(notice) { + var list = notice.find('ul.threaded-replies'); + var placeholder = $('
  • ' + + '' + + '
  • '); + placeholder.click(function() { + SN.U.NoticeInlineReplyTrigger(notice); + }); + placeholder.find('input').val(SN.msg('reply_placeholder')); + list.append(placeholder); + }, + + /** + * Setup function -- DOES NOT apply immediately. + * + * Sets up event handlers for favor/disfavor forms to submit via XHR. + * Uses 'live' rather than 'bind', so applies to future as well as present items. + */ + NoticeInlineReplySetup: function() { + $('.threaded-replies').each(function() { + var list = $(this); + var notice = list.closest('.notice'); + SN.U.NoticeInlineReplyPlaceholder(notice); + }); + }, + + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up event handlers for repeat forms to toss up a confirmation + * popout before submitting. + * + * Uses 'live' rather than 'bind', so applies to future as well as present items. + */ NoticeRepeat: function() { $('.form_repeat').live('click', function(e) { e.preventDefault(); @@ -362,6 +729,22 @@ var SN = { // StatusNet }); }, + /** + * Shows a confirmation dialog box variant of the repeat button form. + * This seems to use a technique where the repeat form contains + * _both_ a standalone button _and_ text and buttons for a dialog. + * The dialog will close after its copy of the form is submitted, + * or if you click its 'close' button. + * + * The dialog is created by duplicating the original form and changing + * its style; while clever, this is hard to generalize and probably + * duplicates a lot of unnecessary HTML output. + * + * @fixme create confirmation dialogs through a generalized interface + * that can be reused instead of hardcoded text and styles. + * + * @param {jQuery} form + */ NoticeRepeatConfirmation: function(form) { var submit_i = form.find('.submit'); @@ -395,12 +778,28 @@ var SN = { // StatusNet }); }, + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Goes through all notices currently displayed and sets up attachment + * handling if needed. + */ NoticeAttachments: function() { $('.notice a.attachment').each(function() { SN.U.NoticeWithAttachment($(this).closest('.notice')); }); }, + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up special attachment link handling if needed. Currently this + * consists only of making the "more" button used for OStatus message + * cropping turn into an auto-expansion button that loads the full + * text from an attachment file. + * + * @param {jQuery} notice + */ NoticeWithAttachment: function(notice) { if (notice.find('.attachment').length === 0) { return; @@ -416,118 +815,216 @@ var SN = { // StatusNet }); return false; - }); + }).attr('title', SN.msg('showmore_tooltip')); } - else { - $.fn.jOverlay.options = { - method : 'GET', - data : '', - url : '', - color : '#000', - opacity : '0.6', - zIndex : 9999, - center : false, - imgLoading : $('address .url')[0].href+'theme/base/images/illustrations/illu_progress_loading-01.gif', - bgClickToClose : true, - success : function() { - $('#jOverlayContent').append(''); - $('#jOverlayContent button').click($.closeOverlay); - }, - timeout : 0, - autoHide : true, - css : {'max-width':'542px', 'top':'5%', 'left':'32.5%'} - }; + }, - notice.find('a.attachment').click(function() { - var attachId = ($(this).attr('id').substring('attachment'.length + 1)); - if (attachId) { - $().jOverlay({url: $('address .url')[0].href+'attachment/' + attachId + '/ajax'}); - return false; - } + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Sets up event handlers for the file-attachment widget in the + * new notice form. When a file is selected, a box will be added + * below the text input showing the filename and, if supported + * by the browser, a thumbnail preview. + * + * This preview box will also allow removing the attachment + * prior to posting. + * + * @param {jQuery} form + */ + NoticeDataAttach: function(form) { + var NDA = form.find('input[type=file]'); + NDA.change(function(event) { + form.find('.attach-status').remove(); + + var filename = $(this).val(); + if (!filename) { + // No file -- we've been tricked! + return false; + } + + var attachStatus = $('
    '); + attachStatus.find('code').text(filename); + attachStatus.find('button').click(function(){ + attachStatus.remove(); + NDA.val(''); + + return false; }); + form.append(attachStatus); - if ($('#shownotice').length == 0) { - var t; - notice.find('a.thumbnail').hover( - function() { - var anchor = $(this); - $('a.thumbnail').children('img').hide(); - anchor.closest(".entry-title").addClass('ov'); - - if (anchor.children('img').length === 0) { - t = setTimeout(function() { - $.get($('address .url')[0].href+'attachment/' + (anchor.attr('id').substring('attachment'.length + 1)) + '/thumbnail', null, function(data) { - anchor.append(data); - }); - }, 500); - } - else { - anchor.children('img').show(); - } - }, - function() { - clearTimeout(t); - $('a.thumbnail').children('img').hide(); - $(this).closest('.entry-title').removeClass('ov'); - } - ); + if (typeof this.files == "object") { + // Some newer browsers will let us fetch the files for preview. + for (var i = 0; i < this.files.length; i++) { + SN.U.PreviewAttach(form, this.files[i]); + } } + }); + }, + + /** + * Get PHP's MAX_FILE_SIZE setting for this form; + * used to apply client-side file size limit checks. + * + * @param {jQuery} form + * @return int max size in bytes; 0 or negative means no limit + */ + maxFileSize: function(form) { + var max = $(form).find('input[name=MAX_FILE_SIZE]').attr('value'); + if (max) { + return parseInt(max); + } else { + return 0; } }, - NoticeDataAttach: function() { - NDA = $('#'+SN.C.S.NoticeDataAttach); - NDA.change(function() { - S = '
    '+$(this).val()+'
    '; - NDAS = $('#'+SN.C.S.NoticeDataAttachSelected); - if (NDAS.length > 0) { - NDAS.replaceWith(S); + /** + * For browsers with FileAPI support: make a thumbnail if possible, + * and append it into the attachment display widget. + * + * Known good: + * - Firefox 3.6.6, 4.0b7 + * - Chrome 8.0.552.210 + * + * Known ok metadata, can't get contents: + * - Safari 5.0.2 + * + * Known fail: + * - Opera 10.63, 11 beta (no input.files interface) + * + * @param {jQuery} form + * @param {File} file + * + * @todo use configured thumbnail size + * @todo detect pixel size? + * @todo should we render a thumbnail to a canvas and then use the smaller image? + */ + PreviewAttach: function(form, file) { + var tooltip = file.type + ' ' + Math.round(file.size / 1024) + 'KB'; + var preview = true; + + var blobAsDataURL; + if (typeof window.createObjectURL != "undefined") { + /** + * createObjectURL lets us reference the file directly from an + * This produces a compact URL with an opaque reference to the file, + * which we can reference immediately. + * + * - Firefox 3.6.6: no + * - Firefox 4.0b7: no + * - Safari 5.0.2: no + * - Chrome 8.0.552.210: works! + */ + blobAsDataURL = function(blob, callback) { + callback(window.createObjectURL(blob)); } - else { - $('#'+SN.C.S.FormNotice).append(S); + } else if (typeof window.FileReader != "undefined") { + /** + * FileAPI's FileReader can build a data URL from a blob's contents, + * but it must read the file and build it asynchronously. This means + * we'll be passing a giant data URL around, which may be inefficient. + * + * - Firefox 3.6.6: works! + * - Firefox 4.0b7: works! + * - Safari 5.0.2: no + * - Chrome 8.0.552.210: works! + */ + blobAsDataURL = function(blob, callback) { + var reader = new FileReader(); + reader.onload = function(event) { + callback(reader.result); + } + reader.readAsDataURL(blob); } - $('#'+SN.C.S.NoticeDataAttachSelected+' button').click(function(){ - $('#'+SN.C.S.NoticeDataAttachSelected).remove(); - NDA.val(''); + } else { + preview = false; + } - return false; + var imageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml']; + if ($.inArray(file.type, imageTypes) == -1) { + // We probably don't know how to show the file. + preview = false; + } + + var maxSize = 8 * 1024 * 1024; + if (file.size > maxSize) { + // Don't kill the browser trying to load some giant image. + preview = false; + } + + if (preview) { + blobAsDataURL(file, function(url) { + var img = $('') + .attr('title', tooltip) + .attr('alt', tooltip) + .attr('src', url) + .attr('style', 'height: 120px'); + form.find('.attach-status').append(img); }); - }); + } else { + var img = $('
    ').text(tooltip); + form.find('.attach-status').append(img); + } }, - NoticeLocationAttach: function() { - var NLat = $('#'+SN.C.S.NoticeLat).val(); - var NLon = $('#'+SN.C.S.NoticeLon).val(); - var NLNS = $('#'+SN.C.S.NoticeLocationNs).val(); - var NLID = $('#'+SN.C.S.NoticeLocationId).val(); - var NLN = $('#'+SN.C.S.NoticeGeoName).text(); - var NDGe = $('#'+SN.C.S.NoticeDataGeo); - - function removeNoticeDataGeo() { - $('label[for='+SN.C.S.NoticeDataGeo+']') - .attr('title', jQuery.trim($('label[for='+SN.C.S.NoticeDataGeo+']').text())) + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Initializes state for the location-lookup features in the + * new-notice form. Seems to set up some event handlers for + * triggering lookups and using the new values. + * + * @param {jQuery} form + * + * @fixme tl;dr + * @fixme there's not good visual state update here, so users have a + * hard time figuring out if it's working or fixing if it's wrong. + * + */ + NoticeLocationAttach: function(form) { + // @fixme this should not be tied to the main notice form, as there may be multiple notice forms... + var NLat = form.find('[name=lat]') + var NLon = form.find('[name=lon]') + var NLNS = form.find('[name=location_ns]').val(); + var NLID = form.find('[name=location_id]').val(); + var NLN = ''; // @fixme + var NDGe = form.find('[name=notice_data-geo]'); + var check = form.find('[name=notice_data-geo]'); + var label = form.find('label.notice_data-geo'); + + function removeNoticeDataGeo(error) { + label + .attr('title', jQuery.trim(label.text())) .removeClass('checked'); - $('#'+SN.C.S.NoticeLat).val(''); - $('#'+SN.C.S.NoticeLon).val(''); - $('#'+SN.C.S.NoticeLocationNs).val(''); - $('#'+SN.C.S.NoticeLocationId).val(''); - $('#'+SN.C.S.NoticeDataGeo).attr('checked', false); + form.find('[name=lat]').val(''); + form.find('[name=lon]').val(''); + form.find('[name=location_ns]').val(''); + form.find('[name=location_id]').val(''); + form.find('[name=notice_data-geo]').attr('checked', false); $.cookie(SN.C.S.NoticeDataGeoCookie, 'disabled', { path: '/' }); + + if (error) { + form.find('.geo_status_wrapper').removeClass('success').addClass('error'); + form.find('.geo_status_wrapper .geo_status').text(error); + } else { + form.find('.geo_status_wrapper').remove(); + } } function getJSONgeocodeURL(geocodeURL, data) { + SN.U.NoticeGeoStatus(form, 'Looking up place name...'); $.getJSON(geocodeURL, data, function(location) { var lns, lid; if (typeof(location.location_ns) != 'undefined') { - $('#'+SN.C.S.NoticeLocationNs).val(location.location_ns); + form.find('[name=location_ns]').val(location.location_ns); lns = location.location_ns; } if (typeof(location.location_id) != 'undefined') { - $('#'+SN.C.S.NoticeLocationId).val(location.location_id); + form.find('[name=location_id]').val(location.location_id); lid = location.location_id; } @@ -538,14 +1035,15 @@ var SN = { // StatusNet NLN_text = location.name; } - $('label[for='+SN.C.S.NoticeDataGeo+']') + SN.U.NoticeGeoStatus(form, NLN_text, data.lat, data.lon, location.url); + label .attr('title', NoticeDataGeo_text.ShareDisable + ' (' + NLN_text + ')'); - $('#'+SN.C.S.NoticeLat).val(data.lat); - $('#'+SN.C.S.NoticeLon).val(data.lon); - $('#'+SN.C.S.NoticeLocationNs).val(lns); - $('#'+SN.C.S.NoticeLocationId).val(lid); - $('#'+SN.C.S.NoticeDataGeo).attr('checked', true); + form.find('[name=lat]').val(data.lat); + form.find('[name=lon]').val(data.lon); + form.find('[name=location_ns]').val(lns); + form.find('[name=location_id]').val(lid); + form.find('[name=notice_data-geo]').attr('checked', true); var cookieValue = { NLat: data.lat, @@ -561,33 +1059,34 @@ var SN = { // StatusNet }); } - if (NDGe.length > 0) { + if (check.length > 0) { if ($.cookie(SN.C.S.NoticeDataGeoCookie) == 'disabled') { - NDGe.attr('checked', false); + check.attr('checked', false); } else { - NDGe.attr('checked', true); + check.attr('checked', true); } - var NGW = $('#notice_data-geo_wrap'); + var NGW = form.find('.notice_data-geo_wrap'); var geocodeURL = NGW.attr('title'); NGW.removeAttr('title'); - $('label[for='+SN.C.S.NoticeDataGeo+']') - .attr('title', jQuery.trim($('label[for='+SN.C.S.NoticeDataGeo+']').text())); + label + .attr('title', label.text()); - NDGe.change(function() { - if ($('#'+SN.C.S.NoticeDataGeo).attr('checked') === true || $.cookie(SN.C.S.NoticeDataGeoCookie) === null) { - $('label[for='+SN.C.S.NoticeDataGeo+']') + check.change(function() { + if (check.attr('checked') === true || $.cookie(SN.C.S.NoticeDataGeoCookie) === null) { + label .attr('title', NoticeDataGeo_text.ShareDisable) .addClass('checked'); if ($.cookie(SN.C.S.NoticeDataGeoCookie) === null || $.cookie(SN.C.S.NoticeDataGeoCookie) == 'disabled') { if (navigator.geolocation) { + SN.U.NoticeGeoStatus(form, 'Requesting location from browser...'); navigator.geolocation.getCurrentPosition( function(position) { - $('#'+SN.C.S.NoticeLat).val(position.coords.latitude); - $('#'+SN.C.S.NoticeLon).val(position.coords.longitude); + form.find('[name=lat]').val(position.coords.latitude); + form.find('[name=lon]').val(position.coords.longitude); var data = { lat: position.coords.latitude, @@ -601,10 +1100,11 @@ var SN = { // StatusNet function(error) { switch(error.code) { case error.PERMISSION_DENIED: - removeNoticeDataGeo(); + removeNoticeDataGeo('Location permission denied.'); break; case error.TIMEOUT: - $('#'+SN.C.S.NoticeDataGeo).attr('checked', false); + //$('#'+SN.C.S.NoticeDataGeo).attr('checked', false); + removeNoticeDataGeo('Location lookup timeout.'); break; } }, @@ -626,21 +1126,22 @@ var SN = { // StatusNet } else { removeNoticeDataGeo(); - $('#'+SN.C.S.NoticeDataGeo).remove(); - $('label[for='+SN.C.S.NoticeDataGeo+']').remove(); + check.remove(); + label.remove(); } } } else { var cookieValue = JSON.parse($.cookie(SN.C.S.NoticeDataGeoCookie)); - $('#'+SN.C.S.NoticeLat).val(cookieValue.NLat); - $('#'+SN.C.S.NoticeLon).val(cookieValue.NLon); - $('#'+SN.C.S.NoticeLocationNs).val(cookieValue.NLNS); - $('#'+SN.C.S.NoticeLocationId).val(cookieValue.NLID); - $('#'+SN.C.S.NoticeDataGeo).attr('checked', cookieValue.NDG); + form.find('[name=lat]').val(cookieValue.NLat); + form.find('[name=lon]').val(cookieValue.NLon); + form.find('[name=location_ns]').val(cookieValue.NLNS); + form.find('[name=location_id]').val(cookieValue.NLID); + form.find('[name=notice_data-geo]').attr('checked', cookieValue.NDG); - $('label[for='+SN.C.S.NoticeDataGeo+']') + SN.U.NoticeGeoStatus(form, cookieValue.NLN, cookieValue.NLat, cookieValue.NLon, cookieValue.NLNU); + label .attr('title', NoticeDataGeo_text.ShareDisable + ' (' + cookieValue.NLN + ')') .addClass('checked'); } @@ -652,6 +1153,54 @@ var SN = { // StatusNet } }, + /** + * Create or update a geolocation status widget in this notice posting form. + * + * @param {jQuery} form + * @param {String} status + * @param {String} lat (optional) + * @param {String} lon (optional) + * @param {String} url (optional) + */ + NoticeGeoStatus: function(form, status, lat, lon, url) + { + var wrapper = form.find('.geo_status_wrapper'); + if (wrapper.length == 0) { + wrapper = $('
    '); + wrapper.find('button.close').click(function() { + form.find('[name=notice_data-geo]').removeAttr('checked').change(); + }); + form.append(wrapper); + } + var label; + if (url) { + label = $('').attr('href', url); + } else { + label = $(''); + } + label.text(status); + if (lat || lon) { + var latlon = lat + ';' + lon; + label.attr('title', latlon); + if (!status) { + label.text(latlon) + } + } + wrapper.find('.geo_status').empty().append(label); + }, + + /** + * Setup function -- DOES NOT trigger actions immediately. + * + * Initializes event handlers for the "Send direct message" link on + * profile pages, setting it up to display a dialog box when clicked. + * + * Unlike the repeat confirmation form, this appears to fetch + * the form _from the original link target_, so the form itself + * doesn't need to be in the current document. + * + * @fixme breaks ability to open link in new window? + */ NewDirectMessage: function() { NDM = $('.entity_send-a-message a'); NDM.attr({'href':NDM.attr('href')+'&ajax=1'}); @@ -680,6 +1229,15 @@ var SN = { // StatusNet }); }, + /** + * Return a date object with the current local time on the + * given year, month, and day. + * + * @param {number} year: 4-digit year + * @param {number} month: 0 == January + * @param {number} day: 1 == 1 + * @return {Date} + */ GetFullYear: function(year, month, day) { var date = new Date(); date.setFullYear(year, month, day); @@ -687,7 +1245,22 @@ var SN = { // StatusNet return date; }, + /** + * Some sort of object interface for storing some structured + * information in a cookie. + * + * Appears to be used to save the last-used login nickname? + * That's something that browsers usually take care of for us + * these days, do we really need to do it? Does anything else + * use this interface? + * + * @fixme what is this? + * @fixme should this use non-cookie local storage when available? + */ StatusNetInstance: { + /** + * @fixme what is this? + */ Set: function(value) { var SNI = SN.U.StatusNetInstance.Get(); if (SNI !== null) { @@ -703,6 +1276,9 @@ var SN = { // StatusNet }); }, + /** + * @fixme what is this? + */ Get: function() { var cookieValue = $.cookie(SN.C.S.StatusNetInstance); if (cookieValue !== null) { @@ -711,6 +1287,9 @@ var SN = { // StatusNet return null; }, + /** + * @fixme what is this? + */ Delete: function() { $.cookie(SN.C.S.StatusNetInstance, null); } @@ -722,6 +1301,9 @@ var SN = { // StatusNet * * @fixme this should be done in a saner way, with machine-readable * info about what page we're looking at. + * + * @param {DOMElement} notice: HTML chunk with formatted notice + * @return boolean */ belongsOnTimeline: function(notice) { var action = $("body").attr('id'); @@ -750,29 +1332,49 @@ var SN = { // StatusNet }, Init: { + /** + * If user is logged in, run setup code for the new notice form: + * + * - char counter + * - AJAX submission + * - location events + * - file upload events + */ NoticeForm: function() { if ($('body.user_in').length > 0) { - SN.U.NoticeLocationAttach(); - $('.'+SN.C.S.FormNotice).each(function() { - SN.U.FormNoticeXHR($(this)); - SN.U.FormNoticeEnhancements($(this)); + var form = $(this); + SN.U.NoticeLocationAttach(form); + SN.U.FormNoticeXHR(form); + SN.U.FormNoticeEnhancements(form); + SN.U.NoticeDataAttach(form); }); - - SN.U.NoticeDataAttach(); } }, + /** + * Run setup code for notice timeline views items: + * + * - AJAX submission for fave/repeat/reply (if logged in) + * - Attachment link extras ('more' links) + */ Notices: function() { if ($('body.user_in').length > 0) { SN.U.NoticeFavor(); SN.U.NoticeRepeat(); SN.U.NoticeReply(); + SN.U.NoticeInlineReplySetup(); } SN.U.NoticeAttachments(); }, + /** + * Run setup code for user & group profile page header area if logged in: + * + * - AJAX submission for sub/unsub/join/leave/nudge + * - AJAX form popup for direct-message + */ EntityActions: function() { if ($('body.user_in').length > 0) { $('.form_user_subscribe').live('click', function() { SN.U.FormXHR($(this)); return false; }); @@ -785,6 +1387,14 @@ var SN = { // StatusNet } }, + /** + * Run setup code for login form: + * + * - loads saved last-used-nickname from cookie + * - sets event handler to save nickname to cookie on submit + * + * @fixme is this necessary? Browsers do their own form saving these days. + */ Login: function() { if (SN.U.StatusNetInstance.Get() !== null) { var nickname = SN.U.StatusNetInstance.Get().Nickname; @@ -797,11 +1407,45 @@ var SN = { // StatusNet SN.U.StatusNetInstance.Set({Nickname: $('#form_login #nickname').val()}); return true; }); + }, + + /** + * Add logic to any file upload forms to handle file size limits, + * on browsers that support basic FileAPI. + */ + UploadForms: function () { + $('input[type=file]').change(function(event) { + if (typeof this.files == "object" && this.files.length > 0) { + var size = 0; + for (var i = 0; i < this.files.length; i++) { + size += this.files[i].size; + } + + var max = SN.U.maxFileSize($(this.form)); + if (max > 0 && size > max) { + var msg = 'File too large: maximum upload size is %d bytes.'; + alert(msg.replace('%d', max)); + + // Clear the files. + $(this).val(''); + event.preventDefault(); + return false; + } + } + }); } } }; +/** + * Run initialization functions on DOM-ready. + * + * Note that if we're waiting on other scripts to load, this won't happen + * until that's done. To load scripts asynchronously without delaying setup, + * don't start them loading until after DOM-ready time! + */ $(document).ready(function(){ + SN.Init.UploadForms(); if ($('.'+SN.C.S.FormNotice).length > 0) { SN.Init.NoticeForm(); } @@ -815,4 +1459,3 @@ $(document).ready(function(){ SN.Init.Login(); } }); -