2 * StatusNet - a distributed open-source microblogging tool
3 * Copyright (C) 2008, StatusNet, Inc.
5 * Add a notice encoded as JSON into the current timeline
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as published by
9 * the Free Software Foundation, either version 3 of the License, or
10 * (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <http://www.gnu.org/licenses/>.
22 * @author Evan Prodromou <evan@status.net>
23 * @author Sarven Capadisli <csarven@status.net>
24 * @copyright 2009 StatusNet, Inc.
25 * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
26 * @link http://status.net/
30 * This is the UI portion of the Realtime plugin base class, handling
31 * queueing up and displaying of notices that have been received through
32 * other code in one of the subclassed plugin implementations such as
35 * Notices are passed in as JSON objects formatted per the Twitter-compatible
38 * @todo Currently we duplicate a lot of formatting and layout code from
39 * the PHP side of StatusNet, which makes it very difficult to maintain
40 * this package. Internationalization as well as newer features such
41 * as location data, customized source links for OStatus profiles,
42 * and image thumbnails are not yet supported in Realtime yet because
43 * they have not been implemented here.
53 _windowhasfocus: true,
59 * Initialize the Realtime plugin UI on a page with a timeline view.
61 * This function is called from a JS fragment inserted by the PHP side
62 * of the Realtime plugin, and provides us with base information
63 * needed to build a near-replica of StatusNet's NoticeListItem output.
65 * Once the UI is initialized, a plugin subclass will need to actually
66 * feed data into the RealtimeUpdate object!
68 * @param {int} userid: local profile ID of the currently logged-in user
69 * @param {String} replyurl: URL for newnotice action, used when generating reply buttons
70 * @param {String} favorurl: URL for favor action, used when generating fave buttons
71 * @param {String} repeaturl: URL for repeat action, used when generating repeat buttons
72 * @param {String} deleteurl: URL template for deletenotice action, used when generating delete buttons.
73 * This URL contains a stub value of 0000000000 which will be replaced with the notice ID.
77 init: function(userid, replyurl, favorurl, repeaturl, deleteurl)
79 RealtimeUpdate._userid = userid;
80 RealtimeUpdate._replyurl = replyurl;
81 RealtimeUpdate._favorurl = favorurl;
82 RealtimeUpdate._repeaturl = repeaturl;
83 RealtimeUpdate._deleteurl = deleteurl;
85 RealtimeUpdate._documenttitle = document.title;
87 $(window).bind('focus', function(){ RealtimeUpdate._windowhasfocus = true; });
89 $(window).bind('blur', function() {
90 $('#notices_primary .notice').removeClass('mark-top');
92 $('#notices_primary .notice:first').addClass('mark-top');
94 RealtimeUpdate._updatecounter = 0;
95 document.title = RealtimeUpdate._documenttitle;
96 RealtimeUpdate._windowhasfocus = false;
103 * Accept a notice in a Twitter-API JSON style and either show it
104 * or queue it up, depending on whether the realtime display is
107 * The meat of a Realtime plugin subclass is to provide a substrate
108 * transport to receive data and shove it into this function. :)
110 * Note that the JSON data is extended from the standard API return
111 * with additional fields added by RealtimePlugin's PHP code.
113 * @param {Object} data: extended JSON API-formatted notice
117 receive: function(data)
119 if (RealtimeUpdate.isNoticeVisible(data.id)) {
120 // Probably posted by the user in this window, and so already
121 // shown by the AJAX form handler. Ignore it.
124 if (RealtimeUpdate._paused === false) {
125 RealtimeUpdate.purgeLastNoticeItem();
127 RealtimeUpdate.insertNoticeItem(data);
130 RealtimeUpdate._queuedNotices.push(data);
132 RealtimeUpdate.updateQueuedCounter();
135 RealtimeUpdate.updateWindowCounter();
139 * Add a visible representation of the given notice at the top of
140 * the current timeline.
142 * If the notice is already in the timeline, nothing will be added.
144 * @param {Object} data: extended JSON API-formatted notice
146 * @fixme while core UI JS code is used to activate the AJAX UI controls,
147 * the actual production of HTML (in makeNoticeItem and its subs)
148 * duplicates core code without plugin hook points or i18n support.
152 insertNoticeItem: function(data) {
153 // Don't add it if it already exists
154 if (RealtimeUpdate.isNoticeVisible(data.id)) {
158 var noticeItem = RealtimeUpdate.makeNoticeItem(data);
159 var noticeItemID = $(noticeItem).attr('id');
161 $("#notices_primary .notices").prepend(noticeItem);
162 $("#notices_primary .notice:first").css({display:"none"});
163 $("#notices_primary .notice:first").fadeIn(1000);
165 SN.U.NoticeReplyTo($('#'+noticeItemID));
166 SN.U.NoticeWithAttachment($('#'+noticeItemID));
170 * Check if the given notice is visible in the timeline currently.
171 * Used to avoid duplicate processing of notices that have been
172 * displayed by other means.
174 * @param {number} id: notice ID to check
180 isNoticeVisible: function(id) {
181 return ($("#notice-"+id).length > 0);
185 * Trims a notice off the end of the timeline if we have more than the
186 * maximum number of notices visible.
190 purgeLastNoticeItem: function() {
191 if ($('#notices_primary .notice').length > RealtimeUpdate._maxnotices) {
192 $("#notices_primary .notice:last").remove();
197 * If the window/tab is in background, increment the counter of newly
198 * received notices and append it onto the window title.
200 * Has no effect if the window is in foreground.
204 updateWindowCounter: function() {
205 if (RealtimeUpdate._windowhasfocus === false) {
206 RealtimeUpdate._updatecounter += 1;
207 document.title = '('+RealtimeUpdate._updatecounter+') ' + RealtimeUpdate._documenttitle;
212 * Builds a notice HTML block from JSON API-style data.
214 * @param {Object} data: extended JSON API-formatted notice
215 * @return {String} HTML fragment
217 * @fixme this replicates core StatusNet code, making maintenance harder
218 * @fixme sloppy HTML building (raw concat without escaping)
219 * @fixme no i18n support
220 * @fixme local variables pollute global namespace
224 makeNoticeItem: function(data)
226 if (data.hasOwnProperty('retweeted_status')) {
227 original = data['retweeted_status'];
230 unique = repeat['id'];
231 responsible = repeat['user'];
236 responsible = data['user'];
240 html = data['html'].replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/&/g,'&');
241 source = data['source'].replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/&/g,'&');
243 ni = "<li class=\"hentry notice\" id=\"notice-"+unique+"\">"+
244 "<div class=\"entry-title\">"+
245 "<span class=\"vcard author\">"+
246 "<a href=\""+user['profile_url']+"\" class=\"url\" title=\""+user['name']+"\">"+
247 "<img src=\""+user['profile_image_url']+"\" class=\"avatar photo\" width=\"48\" height=\"48\" alt=\""+user['screen_name']+"\"/>"+
248 "<span class=\"nickname fn\">"+user['screen_name']+"</span>"+
251 "<p class=\"entry-content\">"+html+"</p>"+
253 "<div class=\"entry-content\">"+
254 "<a class=\"timestamp\" rel=\"bookmark\" href=\""+data['url']+"\" >"+
255 "<abbr class=\"published\" title=\""+data['created_at']+"\">a few seconds ago</abbr>"+
257 "<span class=\"source\">"+
259 "<span class=\"device\">"+source+"</span>"+ // may have a link
261 if (data['conversation_url']) {
262 ni = ni+" <a class=\"response\" href=\""+data['conversation_url']+"\">in context</a>";
267 ni = ni + "<span class=\"repeat vcard\">Repeated by " +
268 "<a href=\"" + ru['profile_url'] + "\" class=\"url\">" +
269 "<span class=\"nickname\">"+ ru['screen_name'] + "</span></a></span>";
274 ni = ni + "<div class=\"notice-options\">";
276 if (RealtimeUpdate._userid != 0) {
277 var input = $("form#form_notice fieldset input#token");
278 var session_key = input.val();
279 ni = ni+RealtimeUpdate.makeFavoriteForm(data['id'], session_key);
280 ni = ni+RealtimeUpdate.makeReplyLink(data['id'], data['user']['screen_name']);
281 if (RealtimeUpdate._userid == responsible['id']) {
282 ni = ni+RealtimeUpdate.makeDeleteLink(data['id']);
283 } else if (RealtimeUpdate._userid != user['id']) {
284 ni = ni+RealtimeUpdate.makeRepeatForm(data['id'], session_key);
295 * Creates a favorite button.
297 * @param {number} id: notice ID to work with
298 * @param {String} session_key: session token for form CSRF protection
299 * @return {String} HTML fragment
301 * @fixme this replicates core StatusNet code, making maintenance harder
302 * @fixme sloppy HTML building (raw concat without escaping)
303 * @fixme no i18n support
307 makeFavoriteForm: function(id, session_key)
311 ff = "<form id=\"favor-"+id+"\" class=\"form_favor\" method=\"post\" action=\""+RealtimeUpdate._favorurl+"\">"+
313 "<legend>Favor this notice</legend>"+
314 "<input name=\"token-"+id+"\" type=\"hidden\" id=\"token-"+id+"\" value=\""+session_key+"\"/>"+
315 "<input name=\"notice\" type=\"hidden\" id=\"notice-n"+id+"\" value=\""+id+"\"/>"+
316 "<input type=\"submit\" id=\"favor-submit-"+id+"\" name=\"favor-submit-"+id+"\" class=\"submit\" value=\"Favor\" title=\"Favor this notice\"/>"+
323 * Creates a reply button.
325 * @param {number} id: notice ID to work with
326 * @param {String} nickname: nick of the user to whom we are replying
327 * @return {String} HTML fragment
329 * @fixme this replicates core StatusNet code, making maintenance harder
330 * @fixme sloppy HTML building (raw concat without escaping)
331 * @fixme no i18n support
335 makeReplyLink: function(id, nickname)
338 rl = "<a class=\"notice_reply\" href=\""+RealtimeUpdate._replyurl+"?replyto="+nickname+"\" title=\"Reply to this notice\">Reply <span class=\"notice_id\">"+id+"</span></a>";
343 * Creates a repeat button.
345 * @param {number} id: notice ID to work with
346 * @param {String} session_key: session token for form CSRF protection
347 * @return {String} HTML fragment
349 * @fixme this replicates core StatusNet code, making maintenance harder
350 * @fixme sloppy HTML building (raw concat without escaping)
351 * @fixme no i18n support
355 makeRepeatForm: function(id, session_key)
358 rf = "<form id=\"repeat-"+id+"\" class=\"form_repeat\" method=\"post\" action=\""+RealtimeUpdate._repeaturl+"\">"+
360 "<legend>Repeat this notice?</legend>"+
361 "<input name=\"token-"+id+"\" type=\"hidden\" id=\"token-"+id+"\" value=\""+session_key+"\"/>"+
362 "<input name=\"notice\" type=\"hidden\" id=\"notice-"+id+"\" value=\""+id+"\"/>"+
363 "<input type=\"submit\" id=\"repeat-submit-"+id+"\" name=\"repeat-submit-"+id+"\" class=\"submit\" value=\"Yes\" title=\"Repeat this notice\"/>"+
371 * Creates a delete button.
373 * @param {number} id: notice ID to create a delete link for
374 * @return {String} HTML fragment
376 * @fixme this replicates core StatusNet code, making maintenance harder
377 * @fixme sloppy HTML building (raw concat without escaping)
378 * @fixme no i18n support
382 makeDeleteLink: function(id)
385 delurl = RealtimeUpdate._deleteurl.replace("0000000000", id);
387 dl = "<a class=\"notice_delete\" href=\""+delurl+"\" title=\"Delete this notice\">Delete</a>";
393 * Adds a control widget at the top of the timeline view, containing
394 * pause/play and popup buttons.
396 * @param {String} url: full URL to the popup window variant of this timeline page
397 * @param {String} timeline: string key for the timeline (eg 'public' or 'evan-all')
398 * @param {String} path: URL to the base directory containing the Realtime plugin,
399 * used to fetch resources if needed.
401 * @todo timeline and path parameters are unused and probably should be removed.
405 initActions: function(url, timeline, path)
407 $('#notices_primary').prepend('<ul id="realtime_actions"><li id="realtime_playpause"></li><li id="realtime_timeline"></li></ul>');
409 RealtimeUpdate._pluginPath = path;
411 RealtimeUpdate.initPlayPause();
412 RealtimeUpdate.initAddPopup(url, timeline, RealtimeUpdate._pluginPath);
416 * Initialize the state of the play/pause controls.
418 * If the browser supports the localStorage interface, we'll attempt
419 * to retrieve a pause state from there; otherwise we default to paused.
423 initPlayPause: function()
425 if (typeof(localStorage) == 'undefined') {
426 RealtimeUpdate.showPause();
429 if (localStorage.getItem('RealtimeUpdate_paused') === 'true') {
430 RealtimeUpdate.showPlay();
433 RealtimeUpdate.showPause();
439 * Switch the realtime UI into paused state.
440 * Uses SN.msg i18n system for the button label and tooltip.
442 * State will be saved and re-used next time if the browser supports
443 * the localStorage interface (via setPause).
447 showPause: function()
449 RealtimeUpdate.setPause(false);
450 RealtimeUpdate.showQueuedNotices();
451 RealtimeUpdate.addNoticesHover();
453 $('#realtime_playpause').remove();
454 $('#realtime_actions').prepend('<li id="realtime_playpause"><button id="realtime_pause" class="pause"></button></li>');
455 $('#realtime_pause').text(SN.msg('realtime_pause'))
456 .attr('title', SN.msg('realtime_pause_tooltip'))
457 .bind('click', function() {
458 RealtimeUpdate.removeNoticesHover();
459 RealtimeUpdate.showPlay();
465 * Switch the realtime UI into play state.
466 * Uses SN.msg i18n system for the button label and tooltip.
468 * State will be saved and re-used next time if the browser supports
469 * the localStorage interface (via setPause).
475 RealtimeUpdate.setPause(true);
476 $('#realtime_playpause').remove();
477 $('#realtime_actions').prepend('<li id="realtime_playpause"><span id="queued_counter"></span> <button id="realtime_play" class="play"></button></li>');
478 $('#realtime_play').text(SN.msg('realtime_play'))
479 .attr('title', SN.msg('realtime_play_tooltip'))
480 .bind('click', function() {
481 RealtimeUpdate.showPause();
487 * Update the internal pause/play state.
488 * Do not call directly; use showPause() and showPlay().
490 * State will be saved and re-used next time if the browser supports
491 * the localStorage interface.
493 * @param {boolean} state: true = paused, false = not paused
497 setPause: function(state)
499 RealtimeUpdate._paused = state;
500 if (typeof(localStorage) != 'undefined') {
501 localStorage.setItem('RealtimeUpdate_paused', RealtimeUpdate._paused);
506 * Go through notices we have previously received while paused,
507 * dumping them into the timeline view.
509 * @fixme long timelines are not trimmed here as they are for things received while not paused
510 * @fixme Ticket #2913: the queued counter on the window title does not get cleared
514 showQueuedNotices: function()
516 $.each(RealtimeUpdate._queuedNotices, function(i, n) {
517 RealtimeUpdate.insertNoticeItem(n);
520 RealtimeUpdate._queuedNotices = [];
522 RealtimeUpdate.removeQueuedCounter();
526 * Update the Realtime widget control's counter of queued notices to show
527 * the current count. This will be called after receiving and queueing
528 * a notice while paused.
532 updateQueuedCounter: function()
534 $('#realtime_playpause #queued_counter').html('('+RealtimeUpdate._queuedNotices.length+')');
538 * Clear the Realtime widget control's counter of queued notices.
542 removeQueuedCounter: function()
544 $('#realtime_playpause #queued_counter').empty();
548 * Set up event handlers on the timeline view to automatically pause
549 * when the mouse is over the timeline, as this indicates the user's
550 * desire to interact with the UI. (Which is hard to do when it's moving!)
554 addNoticesHover: function()
556 $('#notices_primary .notices').hover(
558 if (RealtimeUpdate._paused === false) {
559 RealtimeUpdate.showPlay();
563 if (RealtimeUpdate._paused === true) {
564 RealtimeUpdate.showPause();
571 * Tear down event handlers on the timeline view to automatically pause
572 * when the mouse is over the timeline.
574 * @fixme this appears to remove *ALL* event handlers from the timeline,
575 * which assumes that nobody else is adding any event handlers.
576 * Sloppy -- we should only remove the ones we add.
580 removeNoticesHover: function()
582 $('#notices_primary .notices').unbind();
586 * UI initialization, to be called from Realtime plugin code on regular
589 * Adds a button to the control widget at the top of the timeline view,
590 * allowing creation of a popup window with a more compact real-time
591 * view of the current timeline.
593 * @param {String} url: full URL to the popup window variant of this timeline page
594 * @param {String} timeline: string key for the timeline (eg 'public' or 'evan-all')
595 * @param {String} path: URL to the base directory containing the Realtime plugin,
596 * used to fetch resources if needed.
598 * @todo timeline and path parameters are unused and probably should be removed.
602 initAddPopup: function(url, timeline, path)
604 $('#realtime_timeline').append('<button id="realtime_popup"></button>');
605 $('#realtime_popup').text(SN.msg('realtime_popup'))
606 .attr('title', SN.msg('realtime_popup_tooltip'))
607 .bind('click', function() {
610 'toolbar=no,resizable=yes,scrollbars=yes,status=no,menubar=no,personalbar=no,location=no,width=500,height=550');
617 * UI initialization, to be called from Realtime plugin code on popup
618 * compact timeline pages.
620 * Sets up links in notices to open in a new window.
622 * @fixme fails to do the same for UI links like context view which will
623 * look bad in the tiny chromeless window.
627 initPopupWindow: function()
629 $('.notices .entry-title a, .notices .entry-content a').bind('click', function() {
630 window.open(this.href, '');
635 $('#showstream .entity_profile').css({'width':'69%'});