]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - js/util.js
Notice form cleanup: removing hardcoded id from counter references; prep for reusable...
[quix0rs-gnu-social.git] / js / util.js
1 /*
2  * StatusNet - a distributed open-source microblogging tool
3  * Copyright (C) 2008, StatusNet, Inc.
4  *
5  * This program is free software: you can redistribute it and/or modify
6  * it under the terms of the GNU Affero General Public License as published by
7  * the Free Software Foundation, either version 3 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13  * GNU Affero General Public License for more details.
14  *
15  * You should have received a copy of the GNU Affero General Public License
16  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
17  *
18  * @category  UI interaction
19  * @package   StatusNet
20  * @author    Sarven Capadisli <csarven@status.net>
21  * @author    Evan Prodromou <evan@status.net>
22  * @author    Brion Vibber <brion@status.net>
23  * @copyright 2009,2010 StatusNet, Inc.
24  * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
25  * @link      http://status.net/
26  */
27
28 var SN = { // StatusNet
29     C: { // Config
30         I: { // Init
31             CounterBlackout: false,
32             MaxLength: 140,
33             PatternUsername: /^[0-9a-zA-Z\-_.]*$/,
34             HTTP20x30x: [200, 201, 202, 203, 204, 205, 206, 300, 301, 302, 303, 304, 305, 306, 307]
35         },
36
37         /**
38          * @fixme are these worth the trouble? They seem to mostly just duplicate
39          * themselves while slightly obscuring the actual selector, so it's hard
40          * to pop over to the HTML and find something.
41          *
42          * In theory, minification could reduce them to shorter variable names,
43          * but at present that doesn't happen with yui-compressor.
44          */
45         S: { // Selector
46             Disabled: 'disabled',
47             Warning: 'warning',
48             Error: 'error',
49             Success: 'success',
50             Processing: 'processing',
51             CommandResult: 'command_result',
52             FormNotice: 'form_notice',
53             NoticeInReplyTo: 'notice_in-reply-to',
54             NoticeActionSubmit: 'notice_action-submit',
55             NoticeLat: 'notice_data-lat',
56             NoticeLon: 'notice_data-lon',
57             NoticeLocationId: 'notice_data-location_id',
58             NoticeLocationNs: 'notice_data-location_ns',
59             NoticeGeoName: 'notice_data-geo_name',
60             NoticeDataGeo: 'notice_data-geo',
61             NoticeDataGeoCookie: 'NoticeDataGeo',
62             NoticeDataGeoSelected: 'notice_data-geo_selected',
63             StatusNetInstance:'StatusNetInstance'
64         }
65     },
66
67     /**
68      * Map of localized message strings exported to script from the PHP
69      * side via Action::getScriptMessages().
70      *
71      * Retrieve them via SN.msg(); this array is an implementation detail.
72      *
73      * @access private
74      */
75     messages: {},
76
77     /**
78      * Grabs a localized string that's been previously exported to us
79      * from server-side code via Action::getScriptMessages().
80      *
81      * @example alert(SN.msg('coolplugin-failed'));
82      *
83      * @param {String} key: string key name to pull from message index
84      * @return matching localized message string
85      */
86     msg: function(key) {
87         if (typeof SN.messages[key] == "undefined") {
88             return '[' + key + ']';
89         } else {
90             return SN.messages[key];
91         }
92     },
93
94     U: { // Utils
95         /**
96          * Setup function -- DOES NOT trigger actions immediately.
97          *
98          * Sets up event handlers on the new notice form.
99          *
100          * @param {jQuery} form: jQuery object whose first matching element is the form
101          * @access private
102          */
103         FormNoticeEnhancements: function(form) {
104             if (jQuery.data(form[0], 'ElementData') === undefined) {
105                 MaxLength = form.find('.count').text();
106                 if (typeof(MaxLength) == 'undefined') {
107                      MaxLength = SN.C.I.MaxLength;
108                 }
109                 jQuery.data(form[0], 'ElementData', {MaxLength:MaxLength});
110
111                 SN.U.Counter(form);
112
113                 NDT = form.find('[name=status_textarea]');
114
115                 NDT.bind('keyup', function(e) {
116                     SN.U.Counter(form);
117                 });
118
119                 var delayedUpdate= function(e) {
120                     // Cut and paste events fire *before* the operation,
121                     // so we need to trigger an update in a little bit.
122                     // This would be so much easier if the 'change' event
123                     // actually fired every time the value changed. :P
124                     window.setTimeout(function() {
125                         SN.U.Counter(form);
126                     }, 50);
127                 };
128                 // Note there's still no event for mouse-triggered 'delete'.
129                 NDT.bind('cut', delayedUpdate)
130                    .bind('paste', delayedUpdate);
131             }
132             else {
133                 form.find('.count').text(jQuery.data(form[0], 'ElementData').MaxLength);
134             }
135         },
136
137         /**
138          * To be called from event handlers on the notice import form.
139          * Triggers an update of the remaining-characters counter.
140          *
141          * Additional counter updates will be suppressed during the
142          * next half-second to avoid flooding the layout engine with
143          * updates, followed by another automatic check.
144          *
145          * The maximum length is pulled from data established by
146          * FormNoticeEnhancements.
147          *
148          * @param {jQuery} form: jQuery object whose first element is the notice posting form
149          * @access private
150          */
151         Counter: function(form) {
152             SN.C.I.FormNoticeCurrent = form;
153
154             var MaxLength = jQuery.data(form[0], 'ElementData').MaxLength;
155
156             if (MaxLength <= 0) {
157                 return;
158             }
159
160             var remaining = MaxLength - SN.U.CharacterCount(form);
161             var counter = form.find('.count');
162
163             if (remaining.toString() != counter.text()) {
164                 if (!SN.C.I.CounterBlackout || remaining === 0) {
165                     if (counter.text() != String(remaining)) {
166                         counter.text(remaining);
167                     }
168                     if (remaining < 0) {
169                         form.addClass(SN.C.S.Warning);
170                     } else {
171                         form.removeClass(SN.C.S.Warning);
172                     }
173                     // Skip updates for the next 500ms.
174                     // On slower hardware, updating on every keypress is unpleasant.
175                     if (!SN.C.I.CounterBlackout) {
176                         SN.C.I.CounterBlackout = true;
177                         SN.C.I.FormNoticeCurrent = form;
178                         window.setTimeout("SN.U.ClearCounterBlackout(SN.C.I.FormNoticeCurrent);", 500);
179                     }
180                 }
181             }
182         },
183
184         /**
185          * Pull the count of characters in the current edit field.
186          * Plugins replacing the edit control may need to override this.
187          *
188          * @param {jQuery} form: jQuery object whose first element is the notice posting form
189          * @return number of chars
190          */
191         CharacterCount: function(form) {
192             return form.find('[name=status_textarea]').val().length;
193         },
194
195         /**
196          * Called internally after the counter update blackout period expires;
197          * runs another update to make sure we didn't miss anything.
198          *
199          * @param {jQuery} form: jQuery object whose first element is the notice posting form
200          * @access private
201          */
202         ClearCounterBlackout: function(form) {
203             // Allow keyup events to poke the counter again
204             SN.C.I.CounterBlackout = false;
205             // Check if the string changed since we last looked
206             SN.U.Counter(form);
207         },
208
209         /**
210          * Helper function to rewrite default HTTP form action URLs to HTTPS
211          * so we can actually fetch them when on an SSL page in ssl=sometimes
212          * mode.
213          *
214          * It would be better to output URLs that didn't hardcode protocol
215          * and hostname in the first place...
216          *
217          * @param {String} url
218          * @return string
219          */
220         RewriteAjaxAction: function(url) {
221             // Quick hack: rewrite AJAX submits to HTTPS if they'd fail otherwise.
222             if (document.location.protocol == 'https:' && url.substr(0, 5) == 'http:') {
223                 return url.replace(/^http:\/\/[^:\/]+/, 'https://' + document.location.host);
224             } else {
225                 return url;
226             }
227         },
228
229         /**
230          * Grabs form data and submits it asynchronously, with 'ajax=1'
231          * parameter added to the rest.
232          *
233          * If a successful response includes another form, that form
234          * will be extracted and copied in, replacing the original form.
235          * If there's no form, the first paragraph will be used.
236          *
237          * @fixme can sometimes explode confusingly if returnd data is bogus
238          * @fixme error handling is pretty vague
239          * @fixme can't submit file uploads
240          *
241          * @param {jQuery} form: jQuery object whose first element is a form
242          *
243          * @access public
244          */
245         FormXHR: function(form) {
246             $.ajax({
247                 type: 'POST',
248                 dataType: 'xml',
249                 url: SN.U.RewriteAjaxAction(form.attr('action')),
250                 data: form.serialize() + '&ajax=1',
251                 beforeSend: function(xhr) {
252                     form
253                         .addClass(SN.C.S.Processing)
254                         .find('.submit')
255                             .addClass(SN.C.S.Disabled)
256                             .attr(SN.C.S.Disabled, SN.C.S.Disabled);
257                 },
258                 error: function (xhr, textStatus, errorThrown) {
259                     alert(errorThrown || textStatus);
260                 },
261                 success: function(data, textStatus) {
262                     if (typeof($('form', data)[0]) != 'undefined') {
263                         form_new = document._importNode($('form', data)[0], true);
264                         form.replaceWith(form_new);
265                     }
266                     else {
267                         form.replaceWith(document._importNode($('p', data)[0], true));
268                     }
269                 }
270             });
271         },
272
273         /**
274          * Setup function -- DOES NOT trigger actions immediately.
275          *
276          * Sets up event handlers for special-cased async submission of the
277          * notice-posting form, including some pre-post validation.
278          *
279          * Unlike FormXHR() this does NOT submit the form immediately!
280          * It sets up event handlers so that any method of submitting the
281          * form (click on submit button, enter, submit() etc) will trigger
282          * it properly.
283          *
284          * Also unlike FormXHR(), this system will use a hidden iframe
285          * automatically to handle file uploads via <input type="file">
286          * controls.
287          *
288          * @fixme tl;dr
289          * @fixme vast swaths of duplicate code and really long variable names clutter this function up real bad
290          * @fixme error handling is unreliable
291          * @fixme cookieValue is a global variable, but probably shouldn't be
292          * @fixme saving the location cache cookies should be split out
293          * @fixme some error messages are hardcoded english: needs i18n
294          * @fixme special-case for bookmarklet is confusing and uses a global var "self". Is this ok?
295          *
296          * @param {jQuery} form: jQuery object whose first element is a form
297          *
298          * @access public
299          */
300         FormNoticeXHR: function(form) {
301             SN.C.I.NoticeDataGeo = {};
302             form.append('<input type="hidden" name="ajax" value="1"/>');
303
304             // Make sure we don't have a mixed HTTP/HTTPS submission...
305             form.attr('action', SN.U.RewriteAjaxAction(form.attr('action')));
306
307             /**
308              * Show a response feedback bit under the new-notice dialog.
309              *
310              * @param {String} cls: CSS class name to use ('error' or 'success')
311              * @param {String} text
312              * @access private
313              */
314             var showFeedback = function(cls, text) {
315                 form.append(
316                     $('<p class="form_response"></p>')
317                         .addClass(cls)
318                         .text(text)
319                 );
320             };
321
322             /**
323              * Hide the previous response feedback, if any.
324              */
325             var removeFeedback = function() {
326                 form.find('.form_response').remove();
327             };
328
329             form.ajaxForm({
330                 dataType: 'xml',
331                 timeout: '60000',
332                 beforeSend: function(formData) {
333                     if (form.find('[name=status_textarea]').val() == '') {
334                         form.addClass(SN.C.S.Warning);
335                         return false;
336                     }
337                     form
338                         .addClass(SN.C.S.Processing)
339                         .find('#'+SN.C.S.NoticeActionSubmit)
340                             .addClass(SN.C.S.Disabled)
341                             .attr(SN.C.S.Disabled, SN.C.S.Disabled);
342
343                     SN.U.normalizeGeoData(form);
344
345                     return true;
346                 },
347                 error: function (xhr, textStatus, errorThrown) {
348                     form
349                         .removeClass(SN.C.S.Processing)
350                         .find('#'+SN.C.S.NoticeActionSubmit)
351                             .removeClass(SN.C.S.Disabled)
352                             .removeAttr(SN.C.S.Disabled, SN.C.S.Disabled);
353                     removeFeedback();
354                     if (textStatus == 'timeout') {
355                         // @fixme i18n
356                         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.');
357                     }
358                     else {
359                         var response = SN.U.GetResponseXML(xhr);
360                         if ($('.'+SN.C.S.Error, response).length > 0) {
361                             form.append(document._importNode($('.'+SN.C.S.Error, response)[0], true));
362                         }
363                         else {
364                             if (parseInt(xhr.status) === 0 || jQuery.inArray(parseInt(xhr.status), SN.C.I.HTTP20x30x) >= 0) {
365                                 form
366                                     .resetForm()
367                                     .find('.attach-status').remove();
368                                 SN.U.FormNoticeEnhancements(form);
369                             }
370                             else {
371                                 // @fixme i18n
372                                 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.');
373                             }
374                         }
375                     }
376                 },
377                 success: function(data, textStatus) {
378                     removeFeedback();
379                     var errorResult = $('#'+SN.C.S.Error, data);
380                     if (errorResult.length > 0) {
381                         showFeedback('error', errorResult.text());
382                     }
383                     else {
384                         if($('body')[0].id == 'bookmarklet') {
385                             // @fixme self is not referenced anywhere?
386                             self.close();
387                         }
388
389                         var commandResult = $('#'+SN.C.S.CommandResult, data);
390                         if (commandResult.length > 0) {
391                             showFeedback('success', commandResult.text());
392                         }
393                         else {
394                             // New notice post was successful. If on our timeline, show it!
395                             var notice = document._importNode($('li', data)[0], true);
396                             var notices = $('#notices_primary .notices:first');
397                             if (notices.length > 0 && SN.U.belongsOnTimeline(notice)) {
398                                 if ($('#'+notice.id).length === 0) {
399                                     var notice_irt_value = $('#'+SN.C.S.NoticeInReplyTo).val();
400                                     var notice_irt = '#notices_primary #notice-'+notice_irt_value;
401                                     if($('body')[0].id == 'conversation') {
402                                         if(notice_irt_value.length > 0 && $(notice_irt+' .notices').length < 1) {
403                                             $(notice_irt).append('<ul class="notices"></ul>');
404                                         }
405                                         $($(notice_irt+' .notices')[0]).append(notice);
406                                     }
407                                     else {
408                                         notices.prepend(notice);
409                                     }
410                                     $('#'+notice.id)
411                                         .css({display:'none'})
412                                         .fadeIn(2500);
413                                     SN.U.NoticeWithAttachment($('#'+notice.id));
414                                     SN.U.NoticeReplyTo($('#'+notice.id));
415                                 }
416                             }
417                             else {
418                                 // Not on a timeline that this belongs on?
419                                 // Just show a success message.
420                                 showFeedback('success', $('title', data).text());
421                             }
422                         }
423                         form.resetForm();
424                         form.find('[name=inreplyto]').val('');
425                         form.find('.attach-status').remove();
426                         SN.U.FormNoticeEnhancements(form);
427                     }
428                 },
429                 complete: function(xhr, textStatus) {
430                     form
431                         .removeClass(SN.C.S.Processing)
432                         .find('#'+SN.C.S.NoticeActionSubmit)
433                             .removeAttr(SN.C.S.Disabled)
434                             .removeClass(SN.C.S.Disabled);
435
436                     $('#'+SN.C.S.NoticeLat).val(SN.C.I.NoticeDataGeo.NLat);
437                     $('#'+SN.C.S.NoticeLon).val(SN.C.I.NoticeDataGeo.NLon);
438                     if ($('#'+SN.C.S.NoticeLocationNs)) {
439                         $('#'+SN.C.S.NoticeLocationNs).val(SN.C.I.NoticeDataGeo.NLNS);
440                         $('#'+SN.C.S.NoticeLocationId).val(SN.C.I.NoticeDataGeo.NLID);
441                     }
442                     $('#'+SN.C.S.NoticeDataGeo).attr('checked', SN.C.I.NoticeDataGeo.NDG);
443                 }
444             });
445         },
446
447         normalizeGeoData: function(form) {
448             SN.C.I.NoticeDataGeo.NLat = form.find('[name=lat]').val();
449             SN.C.I.NoticeDataGeo.NLon = form.find('[name=lon]').val();
450             SN.C.I.NoticeDataGeo.NLNS = form.find('[name=location_ns]').val();
451             SN.C.I.NoticeDataGeo.NLID = form.find('[name=location_id]').val();
452             SN.C.I.NoticeDataGeo.NDG = $('#'+SN.C.S.NoticeDataGeo).attr('checked'); // @fixme
453
454             var cookieValue = $.cookie(SN.C.S.NoticeDataGeoCookie);
455
456             if (cookieValue !== null && cookieValue != 'disabled') {
457                 cookieValue = JSON.parse(cookieValue);
458                 SN.C.I.NoticeDataGeo.NLat = form.find('[name=lat]').val(cookieValue.NLat).val();
459                 SN.C.I.NoticeDataGeo.NLon = form.find('[name=lon]').val(cookieValue.NLon).val();
460                 if (cookieValue.NLNS) {
461                     SN.C.I.NoticeDataGeo.NLNS = form.find('[name=location_ns]').val(cookieValue.NLNS).val();
462                     SN.C.I.NoticeDataGeo.NLID = form.find('[name=location_id]').val(cookieValue.NLID).val();
463                 } else {
464                     form.find('[name=location_ns]').val('');
465                     form.find('[name=location_id]').val('');
466                 }
467             }
468             if (cookieValue == 'disabled') {
469                 SN.C.I.NoticeDataGeo.NDG = $('#'+SN.C.S.NoticeDataGeo).attr('checked', false).attr('checked');
470             }
471             else {
472                 SN.C.I.NoticeDataGeo.NDG = $('#'+SN.C.S.NoticeDataGeo).attr('checked', true).attr('checked');
473             }
474
475         },
476         /**
477          * Fetch an XML DOM from an XHR's response data.
478          *
479          * Works around unavailable responseXML when document.domain
480          * has been modified by Meteor or other tools, in some but not
481          * all browsers.
482          *
483          * @param {XMLHTTPRequest} xhr
484          * @return DOMDocument
485          */
486         GetResponseXML: function(xhr) {
487             try {
488                 return xhr.responseXML;
489             } catch (e) {
490                 return (new DOMParser()).parseFromString(xhr.responseText, "text/xml");
491             }
492         },
493
494         /**
495          * Setup function -- DOES NOT trigger actions immediately.
496          *
497          * Sets up event handlers on all visible notice's reply buttons to
498          * tweak the new-notice form with needed variables and focus it
499          * when pushed.
500          *
501          * (This replaces the default reply button behavior to submit
502          * directly to a form which comes back with a specialized page
503          * with the form data prefilled.)
504          *
505          * @access private
506          */
507         NoticeReply: function() {
508             if ($('#content .notice_reply').length > 0) {
509                 $('#content .notice').each(function() { SN.U.NoticeReplyTo($(this)); });
510             }
511         },
512
513         /**
514          * Setup function -- DOES NOT trigger actions immediately.
515          *
516          * Sets up event handlers on the given notice's reply button to
517          * tweak the new-notice form with needed variables and focus it
518          * when pushed.
519          *
520          * (This replaces the default reply button behavior to submit
521          * directly to a form which comes back with a specialized page
522          * with the form data prefilled.)
523          *
524          * @param {jQuery} notice: jQuery object containing one or more notices
525          * @access private
526          */
527         NoticeReplyTo: function(notice) {
528             notice.find('.notice_reply').live('click', function(e) {
529                 e.preventDefault();
530                 var nickname = ($('.author .nickname', notice).length > 0) ? $($('.author .nickname', notice)[0]) : $('.author .nickname.uid');
531                 SN.U.NoticeInlineReplyTrigger(notice, '@' + nickname.text());
532                 return false;
533             });
534         },
535
536         /**
537          * Open up a notice's inline reply box.
538          *
539          * @param {jQuery} notice: jQuery object containing one notice
540          * @param {String} initialText
541          */
542         NoticeInlineReplyTrigger: function(notice, initialText) {
543             // Find the notice we're replying to...
544             var id = $($('.notice_id', notice)[0]).text();
545             var parentNotice = notice;
546
547             // Find the threaded replies view we'll be adding to...
548             var list = notice.closest('.notices');
549             if (list.hasClass('threaded-replies')) {
550                 // We're replying to a reply; use reply form on the end of this list.
551                 // We'll add our form at the end of this; grab the root notice.
552                 parentNotice = list.closest('.notice');
553             } else {
554                 // We're replying to a parent notice; pull its threaded list
555                 // and we'll add on the end of it. Will add if needed.
556                 list = $('ul.threaded-replies', notice);
557                 if (list.length == 0) {
558                     list = $('<ul class="notices threaded-replies xoxo"></ul>');
559                     notice.append(list);
560                 }
561             }
562
563             // See if the form's already open...
564             var replyForm = $('.notice-reply-form', list);
565             if (replyForm.length == 0) {
566                 // Remove placeholder if any
567                 $('li.notice-reply-placeholder').remove();
568
569                 // Create the reply form entry at the end
570                 var replyItem = $('li.notice-reply', list);
571                 if (replyItem.length == 0) {
572                     replyItem = $('<li class="notice-reply">' +
573                                       '<form class="notice-reply-form" method="post">' +
574                                           '<textarea name="status_textarea"></textarea>' +
575                                           '<div class="controls">' +
576                                           '<input type="hidden" name="token">' +
577                                           '<input type="hidden" name="inreplyto">' +
578                                           '<input type="submit" class="submit">' +
579                                       '</div>' +
580                                       '</form>' +
581                                   '</li>');
582
583                     var baseForm = $('#form_notice');
584                     replyForm = replyItem.find('form');
585                     replyForm.attr('action', baseForm.attr('action'));
586                     replyForm.find('input[name="token"]').val(baseForm.find('input[name=token]').val());
587                     replyForm.find('input[type="submit"]').val(SN.msg('reply_submit'));
588                     list.append(replyItem);
589
590                     replyForm.find('textarea').blur(function() {
591                         var textarea = $(this);
592                         var txt = $.trim(textarea.val());
593                         if (txt == '' || txt == textarea.data('initialText')) {
594                             // Nothing to say? Begone!
595                             replyItem.remove();
596                             if (list.find('li').length > 0) {
597                                 SN.U.NoticeInlineReplyPlaceholder(parentNotice);
598                             } else {
599                                 list.remove();
600                             }
601                         }
602                     });
603                     replyForm.submit(function(event) {
604                         var form = replyForm;
605                         $.ajax({
606                             type: 'POST',
607                             dataType: 'xml',
608                             url: SN.U.RewriteAjaxAction(form.attr('action')),
609                             data: form.serialize() + '&ajax=1',
610                             beforeSend: function(xhr) {
611                                 form
612                                     .addClass(SN.C.S.Processing)
613                                     .find('.submit')
614                                         .addClass(SN.C.S.Disabled)
615                                         .attr(SN.C.S.Disabled, SN.C.S.Disabled)
616                                         .end()
617                                     .find('textarea')
618                                         .addClass(SN.C.S.Disabled)
619                                         .attr(SN.C.S.Disabled, SN.C.S.Disabled);
620                             },
621                             error: function (xhr, textStatus, errorThrown) {
622                                 alert(errorThrown || textStatus);
623                             },
624                             success: function(data, textStatus) {
625                                 var orig_li = $('li', data)[0];
626                                 if (orig_li) {
627                                     var li = document._importNode(orig_li, true);
628                                     var id = $(li).attr('id');
629                                     if ($("#"+id).length == 0) {
630                                         replyItem.replaceWith(li);
631                                         SN.U.NoticeInlineReplyPlaceholder(parentNotice);
632                                     } else {
633                                         // Realtime came through before us...
634                                         replyItem.remove();
635                                     }
636                                 }
637                             }
638                         });
639                         event.preventDefault();
640                         return false;
641                     });
642                 }
643             }
644
645             // Override...?
646             replyForm.find('input[name=inreplyto]').val(id);
647
648             // Set focus...
649             var text = replyForm.find('textarea');
650             if (text.length == 0) {
651                 throw "No textarea";
652             }
653             var replyto = '';
654             if (initialText) {
655                 replyto = initialText + ' ';
656             }
657             text.val(replyto + text.val().replace(RegExp(replyto, 'i'), ''));
658             text.data('initialText', $.trim(initialText + ''));
659             text.focus();
660             if (text[0].setSelectionRange) {
661                 var len = text.val().length;
662                 text[0].setSelectionRange(len,len);
663             }
664         },
665
666         /**
667          * Setup function -- DOES NOT apply immediately.
668          *
669          * Sets up event handlers for favor/disfavor forms to submit via XHR.
670          * Uses 'live' rather than 'bind', so applies to future as well as present items.
671          */
672         NoticeFavor: function() {
673             $('.form_favor').live('click', function() { SN.U.FormXHR($(this)); return false; });
674             $('.form_disfavor').live('click', function() { SN.U.FormXHR($(this)); return false; });
675         },
676
677         NoticeInlineReplyPlaceholder: function(notice) {
678             var list = notice.find('ul.threaded-replies');
679             var placeholder = $('<li class="notice-reply-placeholder">' +
680                                     '<input class="placeholder">' +
681                                 '</li>');
682             placeholder.click(function() {
683                 SN.U.NoticeInlineReplyTrigger(notice);
684             });
685             placeholder.find('input').val(SN.msg('reply_placeholder'));
686             list.append(placeholder);
687         },
688
689         /**
690          * Setup function -- DOES NOT apply immediately.
691          *
692          * Sets up event handlers for favor/disfavor forms to submit via XHR.
693          * Uses 'live' rather than 'bind', so applies to future as well as present items.
694          */
695         NoticeInlineReplySetup: function() {
696             $('.threaded-replies').each(function() {
697                 var list = $(this);
698                 var notice = list.closest('.notice');
699                 SN.U.NoticeInlineReplyPlaceholder(notice);
700             });
701         },
702
703         /**
704          * Setup function -- DOES NOT trigger actions immediately.
705          *
706          * Sets up event handlers for repeat forms to toss up a confirmation
707          * popout before submitting.
708          *
709          * Uses 'live' rather than 'bind', so applies to future as well as present items.
710          */
711         NoticeRepeat: function() {
712             $('.form_repeat').live('click', function(e) {
713                 e.preventDefault();
714
715                 SN.U.NoticeRepeatConfirmation($(this));
716                 return false;
717             });
718         },
719
720         /**
721          * Shows a confirmation dialog box variant of the repeat button form.
722          * This seems to use a technique where the repeat form contains
723          * _both_ a standalone button _and_ text and buttons for a dialog.
724          * The dialog will close after its copy of the form is submitted,
725          * or if you click its 'close' button.
726          *
727          * The dialog is created by duplicating the original form and changing
728          * its style; while clever, this is hard to generalize and probably
729          * duplicates a lot of unnecessary HTML output.
730          *
731          * @fixme create confirmation dialogs through a generalized interface
732          * that can be reused instead of hardcoded text and styles.
733          *
734          * @param {jQuery} form
735          */
736         NoticeRepeatConfirmation: function(form) {
737             var submit_i = form.find('.submit');
738
739             var submit = submit_i.clone();
740             submit
741                 .addClass('submit_dialogbox')
742                 .removeClass('submit');
743             form.append(submit);
744             submit.bind('click', function() { SN.U.FormXHR(form); return false; });
745
746             submit_i.hide();
747
748             form
749                 .addClass('dialogbox')
750                 .append('<button class="close">&#215;</button>')
751                 .closest('.notice-options')
752                     .addClass('opaque');
753
754             form.find('button.close').click(function(){
755                 $(this).remove();
756
757                 form
758                     .removeClass('dialogbox')
759                     .closest('.notice-options')
760                         .removeClass('opaque');
761
762                 form.find('.submit_dialogbox').remove();
763                 form.find('.submit').show();
764
765                 return false;
766             });
767         },
768
769         /**
770          * Setup function -- DOES NOT trigger actions immediately.
771          *
772          * Goes through all notices currently displayed and sets up attachment
773          * handling if needed.
774          */
775         NoticeAttachments: function() {
776             $('.notice a.attachment').each(function() {
777                 SN.U.NoticeWithAttachment($(this).closest('.notice'));
778             });
779         },
780
781         /**
782          * Setup function -- DOES NOT trigger actions immediately.
783          *
784          * Sets up special attachment link handling if needed. Currently this
785          * consists only of making the "more" button used for OStatus message
786          * cropping turn into an auto-expansion button that loads the full
787          * text from an attachment file.
788          *
789          * @param {jQuery} notice
790          */
791         NoticeWithAttachment: function(notice) {
792             if (notice.find('.attachment').length === 0) {
793                 return;
794             }
795
796             var attachment_more = notice.find('.attachment.more');
797             if (attachment_more.length > 0) {
798                 $(attachment_more[0]).click(function() {
799                     var m = $(this);
800                     m.addClass(SN.C.S.Processing);
801                     $.get(m.attr('href')+'/ajax', null, function(data) {
802                         m.parent('.entry-content').html($(data).find('#attachment_view .entry-content').html());
803                     });
804
805                     return false;
806                 }).attr('title', SN.msg('showmore_tooltip'));
807             }
808         },
809
810         /**
811          * Setup function -- DOES NOT trigger actions immediately.
812          *
813          * Sets up event handlers for the file-attachment widget in the
814          * new notice form. When a file is selected, a box will be added
815          * below the text input showing the filename and, if supported
816          * by the browser, a thumbnail preview.
817          *
818          * This preview box will also allow removing the attachment
819          * prior to posting.
820          *
821          * @param {jQuery} form
822          */
823         NoticeDataAttach: function(form) {
824             var NDA = form.find('input[type=file]');
825             NDA.change(function(event) {
826                 form.find('.attach-status').remove();
827
828                 var filename = $(this).val();
829                 if (!filename) {
830                     // No file -- we've been tricked!
831                     return false;
832                 }
833
834                 var attachStatus = $('<div class="attach-status '+SN.C.S.Success+'"><code></code> <button class="close">&#215;</button></div>');
835                 attachStatus.find('code').text(filename);
836                 attachStatus.find('button').click(function(){
837                     attachStatus.remove();
838                     NDA.val('');
839
840                     return false;
841                 });
842                 form.append(attachStatus);
843
844                 if (typeof this.files == "object") {
845                     // Some newer browsers will let us fetch the files for preview.
846                     for (var i = 0; i < this.files.length; i++) {
847                         SN.U.PreviewAttach(form, this.files[i]);
848                     }
849                 }
850             });
851         },
852
853         /**
854          * Get PHP's MAX_FILE_SIZE setting for this form;
855          * used to apply client-side file size limit checks.
856          *
857          * @param {jQuery} form
858          * @return int max size in bytes; 0 or negative means no limit
859          */
860         maxFileSize: function(form) {
861             var max = $(form).find('input[name=MAX_FILE_SIZE]').attr('value');
862             if (max) {
863                 return parseInt(max);
864             } else {
865                 return 0;
866             }
867         },
868
869         /**
870          * For browsers with FileAPI support: make a thumbnail if possible,
871          * and append it into the attachment display widget.
872          *
873          * Known good:
874          * - Firefox 3.6.6, 4.0b7
875          * - Chrome 8.0.552.210
876          *
877          * Known ok metadata, can't get contents:
878          * - Safari 5.0.2
879          *
880          * Known fail:
881          * - Opera 10.63, 11 beta (no input.files interface)
882          *
883          * @param {jQuery} form
884          * @param {File} file
885          *
886          * @todo use configured thumbnail size
887          * @todo detect pixel size?
888          * @todo should we render a thumbnail to a canvas and then use the smaller image?
889          */
890         PreviewAttach: function(form, file) {
891             var tooltip = file.type + ' ' + Math.round(file.size / 1024) + 'KB';
892             var preview = true;
893
894             var blobAsDataURL;
895             if (typeof window.createObjectURL != "undefined") {
896                 /**
897                  * createObjectURL lets us reference the file directly from an <img>
898                  * This produces a compact URL with an opaque reference to the file,
899                  * which we can reference immediately.
900                  *
901                  * - Firefox 3.6.6: no
902                  * - Firefox 4.0b7: no
903                  * - Safari 5.0.2: no
904                  * - Chrome 8.0.552.210: works!
905                  */
906                 blobAsDataURL = function(blob, callback) {
907                     callback(window.createObjectURL(blob));
908                 }
909             } else if (typeof window.FileReader != "undefined") {
910                 /**
911                  * FileAPI's FileReader can build a data URL from a blob's contents,
912                  * but it must read the file and build it asynchronously. This means
913                  * we'll be passing a giant data URL around, which may be inefficient.
914                  *
915                  * - Firefox 3.6.6: works!
916                  * - Firefox 4.0b7: works!
917                  * - Safari 5.0.2: no
918                  * - Chrome 8.0.552.210: works!
919                  */
920                 blobAsDataURL = function(blob, callback) {
921                     var reader = new FileReader();
922                     reader.onload = function(event) {
923                         callback(reader.result);
924                     }
925                     reader.readAsDataURL(blob);
926                 }
927             } else {
928                 preview = false;
929             }
930
931             var imageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml'];
932             if ($.inArray(file.type, imageTypes) == -1) {
933                 // We probably don't know how to show the file.
934                 preview = false;
935             }
936
937             var maxSize = 8 * 1024 * 1024;
938             if (file.size > maxSize) {
939                 // Don't kill the browser trying to load some giant image.
940                 preview = false;
941             }
942
943             if (preview) {
944                 blobAsDataURL(file, function(url) {
945                     var img = $('<img>')
946                         .attr('title', tooltip)
947                         .attr('alt', tooltip)
948                         .attr('src', url)
949                         .attr('style', 'height: 120px');
950                     form.find('.attach-status').append(img);
951                 });
952             } else {
953                 var img = $('<div></div>').text(tooltip);
954                 form.find('.attach-status').append(img);
955             }
956         },
957
958         /**
959          * Setup function -- DOES NOT trigger actions immediately.
960          *
961          * Initializes state for the location-lookup features in the
962          * new-notice form. Seems to set up some event handlers for
963          * triggering lookups and using the new values.
964          *
965          * @fixme tl;dr
966          * @fixme there's not good visual state update here, so users have a
967          *        hard time figuring out if it's working or fixing if it's wrong.
968          *
969          */
970         NoticeLocationAttach: function() {
971             // @fixme this should not be tied to the main notice form, as there may be multiple notice forms...
972             var NLat = $('#'+SN.C.S.NoticeLat).val();
973             var NLon = $('#'+SN.C.S.NoticeLon).val();
974             var NLNS = $('#'+SN.C.S.NoticeLocationNs).val();
975             var NLID = $('#'+SN.C.S.NoticeLocationId).val();
976             var NLN = $('#'+SN.C.S.NoticeGeoName).text();
977             var NDGe = $('#'+SN.C.S.NoticeDataGeo);
978
979             function removeNoticeDataGeo(error) {
980                 $('label[for='+SN.C.S.NoticeDataGeo+']')
981                     .attr('title', jQuery.trim($('label[for='+SN.C.S.NoticeDataGeo+']').text()))
982                     .removeClass('checked');
983
984                 $('#'+SN.C.S.NoticeLat).val('');
985                 $('#'+SN.C.S.NoticeLon).val('');
986                 $('#'+SN.C.S.NoticeLocationNs).val('');
987                 $('#'+SN.C.S.NoticeLocationId).val('');
988                 $('#'+SN.C.S.NoticeDataGeo).attr('checked', false);
989
990                 $.cookie(SN.C.S.NoticeDataGeoCookie, 'disabled', { path: '/' });
991
992                 if (error) {
993                     $('.geo_status_wrapper').removeClass('success').addClass('error');
994                     $('.geo_status_wrapper .geo_status').text(error);
995                 } else {
996                     $('.geo_status_wrapper').remove();
997                 }
998             }
999
1000             function getJSONgeocodeURL(geocodeURL, data) {
1001                 SN.U.NoticeGeoStatus('Looking up place name...');
1002                 $.getJSON(geocodeURL, data, function(location) {
1003                     var lns, lid;
1004
1005                     if (typeof(location.location_ns) != 'undefined') {
1006                         $('#'+SN.C.S.NoticeLocationNs).val(location.location_ns);
1007                         lns = location.location_ns;
1008                     }
1009
1010                     if (typeof(location.location_id) != 'undefined') {
1011                         $('#'+SN.C.S.NoticeLocationId).val(location.location_id);
1012                         lid = location.location_id;
1013                     }
1014
1015                     if (typeof(location.name) == 'undefined') {
1016                         NLN_text = data.lat + ';' + data.lon;
1017                     }
1018                     else {
1019                         NLN_text = location.name;
1020                     }
1021
1022                     SN.U.NoticeGeoStatus(NLN_text, data.lat, data.lon, location.url);
1023                     $('label[for='+SN.C.S.NoticeDataGeo+']')
1024                         .attr('title', NoticeDataGeo_text.ShareDisable + ' (' + NLN_text + ')');
1025
1026                     $('#'+SN.C.S.NoticeLat).val(data.lat);
1027                     $('#'+SN.C.S.NoticeLon).val(data.lon);
1028                     $('#'+SN.C.S.NoticeLocationNs).val(lns);
1029                     $('#'+SN.C.S.NoticeLocationId).val(lid);
1030                     $('#'+SN.C.S.NoticeDataGeo).attr('checked', true);
1031
1032                     var cookieValue = {
1033                         NLat: data.lat,
1034                         NLon: data.lon,
1035                         NLNS: lns,
1036                         NLID: lid,
1037                         NLN: NLN_text,
1038                         NLNU: location.url,
1039                         NDG: true
1040                     };
1041
1042                     $.cookie(SN.C.S.NoticeDataGeoCookie, JSON.stringify(cookieValue), { path: '/' });
1043                 });
1044             }
1045
1046             if (NDGe.length > 0) {
1047                 if ($.cookie(SN.C.S.NoticeDataGeoCookie) == 'disabled') {
1048                     NDGe.attr('checked', false);
1049                 }
1050                 else {
1051                     NDGe.attr('checked', true);
1052                 }
1053
1054                 var NGW = $('#notice_data-geo_wrap');
1055                 var geocodeURL = NGW.attr('title');
1056                 NGW.removeAttr('title');
1057
1058                 $('label[for='+SN.C.S.NoticeDataGeo+']')
1059                     .attr('title', jQuery.trim($('label[for='+SN.C.S.NoticeDataGeo+']').text()));
1060
1061                 NDGe.change(function() {
1062                     if ($('#'+SN.C.S.NoticeDataGeo).attr('checked') === true || $.cookie(SN.C.S.NoticeDataGeoCookie) === null) {
1063                         $('label[for='+SN.C.S.NoticeDataGeo+']')
1064                             .attr('title', NoticeDataGeo_text.ShareDisable)
1065                             .addClass('checked');
1066
1067                         if ($.cookie(SN.C.S.NoticeDataGeoCookie) === null || $.cookie(SN.C.S.NoticeDataGeoCookie) == 'disabled') {
1068                             if (navigator.geolocation) {
1069                                 SN.U.NoticeGeoStatus('Requesting location from browser...');
1070                                 navigator.geolocation.getCurrentPosition(
1071                                     function(position) {
1072                                         $('#'+SN.C.S.NoticeLat).val(position.coords.latitude);
1073                                         $('#'+SN.C.S.NoticeLon).val(position.coords.longitude);
1074
1075                                         var data = {
1076                                             lat: position.coords.latitude,
1077                                             lon: position.coords.longitude,
1078                                             token: $('#token').val()
1079                                         };
1080
1081                                         getJSONgeocodeURL(geocodeURL, data);
1082                                     },
1083
1084                                     function(error) {
1085                                         switch(error.code) {
1086                                             case error.PERMISSION_DENIED:
1087                                                 removeNoticeDataGeo('Location permission denied.');
1088                                                 break;
1089                                             case error.TIMEOUT:
1090                                                 //$('#'+SN.C.S.NoticeDataGeo).attr('checked', false);
1091                                                 removeNoticeDataGeo('Location lookup timeout.');
1092                                                 break;
1093                                         }
1094                                     },
1095
1096                                     {
1097                                         timeout: 10000
1098                                     }
1099                                 );
1100                             }
1101                             else {
1102                                 if (NLat.length > 0 && NLon.length > 0) {
1103                                     var data = {
1104                                         lat: NLat,
1105                                         lon: NLon,
1106                                         token: $('#token').val()
1107                                     };
1108
1109                                     getJSONgeocodeURL(geocodeURL, data);
1110                                 }
1111                                 else {
1112                                     removeNoticeDataGeo();
1113                                     $('#'+SN.C.S.NoticeDataGeo).remove();
1114                                     $('label[for='+SN.C.S.NoticeDataGeo+']').remove();
1115                                 }
1116                             }
1117                         }
1118                         else {
1119                             var cookieValue = JSON.parse($.cookie(SN.C.S.NoticeDataGeoCookie));
1120
1121                             $('#'+SN.C.S.NoticeLat).val(cookieValue.NLat);
1122                             $('#'+SN.C.S.NoticeLon).val(cookieValue.NLon);
1123                             $('#'+SN.C.S.NoticeLocationNs).val(cookieValue.NLNS);
1124                             $('#'+SN.C.S.NoticeLocationId).val(cookieValue.NLID);
1125                             $('#'+SN.C.S.NoticeDataGeo).attr('checked', cookieValue.NDG);
1126
1127                             SN.U.NoticeGeoStatus(cookieValue.NLN, cookieValue.NLat, cookieValue.NLon, cookieValue.NLNU);
1128                             $('label[for='+SN.C.S.NoticeDataGeo+']')
1129                                 .attr('title', NoticeDataGeo_text.ShareDisable + ' (' + cookieValue.NLN + ')')
1130                                 .addClass('checked');
1131                         }
1132                     }
1133                     else {
1134                         removeNoticeDataGeo();
1135                     }
1136                 }).change();
1137             }
1138         },
1139
1140         /**
1141          * Create or update a geolocation status widget in this notice posting form.
1142          *
1143          * @param {String} status
1144          * @param {String} lat (optional)
1145          * @param {String} lon (optional)
1146          * @param {String} url (optional)
1147          */
1148         NoticeGeoStatus: function(status, lat, lon, url)
1149         {
1150             var form = $('#form_notice');
1151             var wrapper = form.find('.geo_status_wrapper');
1152             if (wrapper.length == 0) {
1153                 wrapper = $('<div class="'+SN.C.S.Success+' geo_status_wrapper"><button class="close" style="float:right">&#215;</button><div class="geo_status"></div></div>');
1154                 wrapper.find('button.close').click(function() {
1155                     $('#'+SN.C.S.NoticeDataGeo).removeAttr('checked').change();
1156                 });
1157                 form.append(wrapper);
1158             }
1159             var label;
1160             if (url) {
1161                 label = $('<a></a>').attr('href', url);
1162             } else {
1163                 label = $('<span></span>');
1164             }
1165             label.text(status);
1166             if (lat || lon) {
1167                 var latlon = lat + ';' + lon;
1168                 label.attr('title', latlon);
1169                 if (!status) {
1170                     label.text(latlon)
1171                 }
1172             }
1173             wrapper.find('.geo_status').empty().append(label);
1174         },
1175
1176         /**
1177          * Setup function -- DOES NOT trigger actions immediately.
1178          *
1179          * Initializes event handlers for the "Send direct message" link on
1180          * profile pages, setting it up to display a dialog box when clicked.
1181          *
1182          * Unlike the repeat confirmation form, this appears to fetch
1183          * the form _from the original link target_, so the form itself
1184          * doesn't need to be in the current document.
1185          *
1186          * @fixme breaks ability to open link in new window?
1187          */
1188         NewDirectMessage: function() {
1189             NDM = $('.entity_send-a-message a');
1190             NDM.attr({'href':NDM.attr('href')+'&ajax=1'});
1191             NDM.bind('click', function() {
1192                 var NDMF = $('.entity_send-a-message form');
1193                 if (NDMF.length === 0) {
1194                     $(this).addClass(SN.C.S.Processing);
1195                     $.get(NDM.attr('href'), null, function(data) {
1196                         $('.entity_send-a-message').append(document._importNode($('form', data)[0], true));
1197                         NDMF = $('.entity_send-a-message .form_notice');
1198                         SN.U.FormNoticeXHR(NDMF);
1199                         SN.U.FormNoticeEnhancements(NDMF);
1200                         NDMF.append('<button class="close">&#215;</button>');
1201                         $('.entity_send-a-message button').click(function(){
1202                             NDMF.hide();
1203                             return false;
1204                         });
1205                         NDM.removeClass(SN.C.S.Processing);
1206                     });
1207                 }
1208                 else {
1209                     NDMF.show();
1210                     $('.entity_send-a-message textarea').focus();
1211                 }
1212                 return false;
1213             });
1214         },
1215
1216         /**
1217          * Return a date object with the current local time on the
1218          * given year, month, and day.
1219          *
1220          * @param {number} year: 4-digit year
1221          * @param {number} month: 0 == January
1222          * @param {number} day: 1 == 1
1223          * @return {Date}
1224          */
1225         GetFullYear: function(year, month, day) {
1226             var date = new Date();
1227             date.setFullYear(year, month, day);
1228
1229             return date;
1230         },
1231
1232         /**
1233          * Some sort of object interface for storing some structured
1234          * information in a cookie.
1235          *
1236          * Appears to be used to save the last-used login nickname?
1237          * That's something that browsers usually take care of for us
1238          * these days, do we really need to do it? Does anything else
1239          * use this interface?
1240          *
1241          * @fixme what is this?
1242          * @fixme should this use non-cookie local storage when available?
1243          */
1244         StatusNetInstance: {
1245             /**
1246              * @fixme what is this?
1247              */
1248             Set: function(value) {
1249                 var SNI = SN.U.StatusNetInstance.Get();
1250                 if (SNI !== null) {
1251                     value = $.extend(SNI, value);
1252                 }
1253
1254                 $.cookie(
1255                     SN.C.S.StatusNetInstance,
1256                     JSON.stringify(value),
1257                     {
1258                         path: '/',
1259                         expires: SN.U.GetFullYear(2029, 0, 1)
1260                     });
1261             },
1262
1263             /**
1264              * @fixme what is this?
1265              */
1266             Get: function() {
1267                 var cookieValue = $.cookie(SN.C.S.StatusNetInstance);
1268                 if (cookieValue !== null) {
1269                     return JSON.parse(cookieValue);
1270                 }
1271                 return null;
1272             },
1273
1274             /**
1275              * @fixme what is this?
1276              */
1277             Delete: function() {
1278                 $.cookie(SN.C.S.StatusNetInstance, null);
1279             }
1280         },
1281
1282         /**
1283          * Check if the current page is a timeline where the current user's
1284          * posts should be displayed immediately on success.
1285          *
1286          * @fixme this should be done in a saner way, with machine-readable
1287          * info about what page we're looking at.
1288          *
1289          * @param {DOMElement} notice: HTML chunk with formatted notice
1290          * @return boolean
1291          */
1292         belongsOnTimeline: function(notice) {
1293             var action = $("body").attr('id');
1294             if (action == 'public') {
1295                 return true;
1296             }
1297
1298             var profileLink = $('#nav_profile a').attr('href');
1299             if (profileLink) {
1300                 var authorUrl = $(notice).find('.entry-title .author a.url').attr('href');
1301                 if (authorUrl == profileLink) {
1302                     if (action == 'all' || action == 'showstream') {
1303                         // Posts always show on your own friends and profile streams.
1304                         return true;
1305                     }
1306                 }
1307             }
1308
1309             // @fixme tag, group, reply timelines should be feasible as well.
1310             // Mismatch between id-based and name-based user/group links currently complicates
1311             // the lookup, since all our inline mentions contain the absolute links but the
1312             // UI links currently on the page use malleable names.
1313
1314             return false;
1315         }
1316     },
1317
1318     Init: {
1319         /**
1320          * If user is logged in, run setup code for the new notice form:
1321          *
1322          *  - char counter
1323          *  - AJAX submission
1324          *  - location events
1325          *  - file upload events
1326          */
1327         NoticeForm: function() {
1328             if ($('body.user_in').length > 0) {
1329                 SN.U.NoticeLocationAttach();
1330
1331                 $('.'+SN.C.S.FormNotice).each(function() {
1332                     SN.U.FormNoticeXHR($(this));
1333                     SN.U.FormNoticeEnhancements($(this));
1334                     SN.U.NoticeDataAttach($(this));
1335                 });
1336             }
1337         },
1338
1339         /**
1340          * Run setup code for notice timeline views items:
1341          *
1342          * - AJAX submission for fave/repeat/reply (if logged in)
1343          * - Attachment link extras ('more' links)
1344          */
1345         Notices: function() {
1346             if ($('body.user_in').length > 0) {
1347                 SN.U.NoticeFavor();
1348                 SN.U.NoticeRepeat();
1349                 SN.U.NoticeReply();
1350                 SN.U.NoticeInlineReplySetup();
1351             }
1352
1353             SN.U.NoticeAttachments();
1354         },
1355
1356         /**
1357          * Run setup code for user & group profile page header area if logged in:
1358          *
1359          * - AJAX submission for sub/unsub/join/leave/nudge
1360          * - AJAX form popup for direct-message
1361          */
1362         EntityActions: function() {
1363             if ($('body.user_in').length > 0) {
1364                 $('.form_user_subscribe').live('click', function() { SN.U.FormXHR($(this)); return false; });
1365                 $('.form_user_unsubscribe').live('click', function() { SN.U.FormXHR($(this)); return false; });
1366                 $('.form_group_join').live('click', function() { SN.U.FormXHR($(this)); return false; });
1367                 $('.form_group_leave').live('click', function() { SN.U.FormXHR($(this)); return false; });
1368                 $('.form_user_nudge').live('click', function() { SN.U.FormXHR($(this)); return false; });
1369
1370                 SN.U.NewDirectMessage();
1371             }
1372         },
1373
1374         /**
1375          * Run setup code for login form:
1376          *
1377          * - loads saved last-used-nickname from cookie
1378          * - sets event handler to save nickname to cookie on submit
1379          *
1380          * @fixme is this necessary? Browsers do their own form saving these days.
1381          */
1382         Login: function() {
1383             if (SN.U.StatusNetInstance.Get() !== null) {
1384                 var nickname = SN.U.StatusNetInstance.Get().Nickname;
1385                 if (nickname !== null) {
1386                     $('#form_login #nickname').val(nickname);
1387                 }
1388             }
1389
1390             $('#form_login').bind('submit', function() {
1391                 SN.U.StatusNetInstance.Set({Nickname: $('#form_login #nickname').val()});
1392                 return true;
1393             });
1394         },
1395
1396         /**
1397          * Add logic to any file upload forms to handle file size limits,
1398          * on browsers that support basic FileAPI.
1399          */
1400         UploadForms: function () {
1401             $('input[type=file]').change(function(event) {
1402                 if (typeof this.files == "object" && this.files.length > 0) {
1403                     var size = 0;
1404                     for (var i = 0; i < this.files.length; i++) {
1405                         size += this.files[i].size;
1406                     }
1407
1408                     var max = SN.U.maxFileSize($(this.form));
1409                     if (max > 0 && size > max) {
1410                         var msg = 'File too large: maximum upload size is %d bytes.';
1411                         alert(msg.replace('%d', max));
1412
1413                         // Clear the files.
1414                         $(this).val('');
1415                         event.preventDefault();
1416                         return false;
1417                     }
1418                 }
1419             });
1420         }
1421     }
1422 };
1423
1424 /**
1425  * Run initialization functions on DOM-ready.
1426  *
1427  * Note that if we're waiting on other scripts to load, this won't happen
1428  * until that's done. To load scripts asynchronously without delaying setup,
1429  * don't start them loading until after DOM-ready time!
1430  */
1431 $(document).ready(function(){
1432     SN.Init.UploadForms();
1433     if ($('.'+SN.C.S.FormNotice).length > 0) {
1434         SN.Init.NoticeForm();
1435     }
1436     if ($('#content .notices').length > 0) {
1437         SN.Init.Notices();
1438     }
1439     if ($('#content .entity_actions').length > 0) {
1440         SN.Init.EntityActions();
1441     }
1442     if ($('#form_login').length > 0) {
1443         SN.Init.Login();
1444     }
1445 });