Update Sone on “view Sone” page, too.
[Sone.git] / src / main / resources / static / javascript / sone.js
index 6ff4ceb..838b669 100644 (file)
@@ -33,11 +33,12 @@ function registerInputTextareaSwap(inputElement, defaultText, inputFieldName, op
                                inputField.val(defaultText);
                        }
                }).hide().data("inputField", $(this)).val($(this).val());
-               $(this).after(textarea);
+               $(this).data("textarea", textarea).after(textarea);
                (function(inputField, textarea) {
                        inputField.focus(function() {
                                $(this).hide().attr("disabled", "disabled");
-                               textarea.show().focus();
+                               /* no, show(), “display: block” is not what I need. */
+                               textarea.attr("style", "display: inline").focus();
                        });
                        if (inputField.val() == "") {
                                inputField.addClass("default");
@@ -49,6 +50,7 @@ function registerInputTextareaSwap(inputElement, defaultText, inputFieldName, op
                        $(inputField.get(0).form).submit(function() {
                                inputField.attr("disabled", "disabled");
                                if (!optional && (textarea.val() == "")) {
+                                       inputField.removeAttr("disabled").focus();
                                        return false;
                                }
                        });
@@ -64,14 +66,13 @@ function registerInputTextareaSwap(inputElement, defaultText, inputFieldName, op
  * @param element
  *            The element to add a “comment” link to
  */
-function addCommentLink(postId, element, insertAfterThisElement) {
+function addCommentLink(postId, author, element, insertAfterThisElement) {
        if (($(element).find(".show-reply-form").length > 0) || (getPostElement(element).find(".create-reply").length == 0)) {
                return;
        }
-       commentElement = (function(postId) {
+       commentElement = (function(postId, author) {
                separator = $("<span> · </span>").addClass("separator");
                var commentElement = $("<div><span>Comment</span></div>").addClass("show-reply-form").click(function() {
-                       markPostAsKnown(getPostElement(this));
                        replyElement = $("#sone .post#" + postId + " .create-reply");
                        replyElement.removeClass("hidden");
                        replyElement.removeClass("light");
@@ -84,10 +85,11 @@ function addCommentLink(postId, element, insertAfterThisElement) {
                                        replyElement.removeClass("light");
                                });
                        })(replyElement);
-                       replyElement.find("input.reply-input").focus();
+                       textArea = replyElement.find("input.reply-input").focus().data("textarea");
+                       textArea.val(textArea.val() + "@sone://" + author + " ");
                });
                return commentElement;
-       })(postId);
+       })(postId, author);
        $(insertAfterThisElement).after(commentElement.clone(true));
        $(insertAfterThisElement).after(separator);
 }
@@ -144,7 +146,7 @@ function filterSoneId(soneId) {
  * @param lastUpdated
  *            The date and time of the last update (formatted for display)
  */
-function updateSoneStatus(soneId, name, status, modified, locked, lastUpdated) {
+function updateSoneStatus(soneId, name, status, modified, locked, lastUpdated, lastUpdatedText) {
        $("#sone .sone." + filterSoneId(soneId)).
                toggleClass("unknown", status == "unknown").
                toggleClass("idle", status == "idle").
@@ -154,7 +156,7 @@ function updateSoneStatus(soneId, name, status, modified, locked, lastUpdated) {
        $("#sone .sone." + filterSoneId(soneId) + " .lock").toggleClass("hidden", locked);
        $("#sone .sone." + filterSoneId(soneId) + " .unlock").toggleClass("hidden", !locked);
        if (lastUpdated != null) {
-               $("#sone .sone." + filterSoneId(soneId) + " .last-update span.time").text(lastUpdated);
+               $("#sone .sone." + filterSoneId(soneId) + " .last-update span.time").attr("title", lastUpdated).text(lastUpdatedText);
        } else {
                getTranslation("View.Sone.Text.UnknownDate", function(unknown) {
                        $("#sone .sone." + filterSoneId(soneId) + " .last-update span.time").text(unknown);
@@ -217,7 +219,8 @@ function enhanceDeletePostButton(button, postId, text) {
                        if (data.success) {
                                $("#sone .post#" + postId).slideUp();
                        } else if (data.error == "invalid-post-id") {
-                               alert("Invalid post ID given!");
+                               /* pretend the post is already gone. */
+                               getPost(postId).slideUp();
                        } else if (data.error == "auth-required") {
                                alert("You need to be logged in.");
                        } else if (data.error == "not-authorized") {
@@ -248,7 +251,8 @@ function enhanceDeleteReplyButton(button, replyId, text) {
                        if (data.success) {
                                $("#sone .reply#" + replyId).slideUp();
                        } else if (data.error == "invalid-reply-id") {
-                               alert("Invalid reply ID given!");
+                               /* pretend the reply is already gone. */
+                               getReply(replyId).slideUp();
                        } else if (data.error == "auth-required") {
                                alert("You need to be logged in.");
                        } else if (data.error == "not-authorized") {
@@ -264,6 +268,19 @@ function getFormPassword() {
        return $("#sone #formPassword").text();
 }
 
+/**
+ * Returns the element of the Sone with the given ID.
+ *
+ * @param soneId
+ *            The ID of the Sone
+ * @returns All Sone elements with the given ID
+ */
+function getSone(soneId) {
+       return $("#sone .sone").filter(function(index) {
+               return $(".id", this).text() == soneId;
+       });
+}
+
 function getSoneElement(element) {
        return $(element).closest(".sone");
 }
@@ -298,6 +315,17 @@ function getSoneId(element) {
        return getSoneElement(element).find(".id").text();
 }
 
+/**
+ * Returns the element of the post with the given ID.
+ *
+ * @param postId
+ *            The ID of the post
+ * @returns The element of the post
+ */
+function getPost(postId) {
+       return $("#sone .post#" + postId);
+}
+
 function getPostElement(element) {
        return $(element).closest(".post");
 }
@@ -321,6 +349,17 @@ function getPostAuthor(element) {
        return getPostElement(element).find(".post-author").text();
 }
 
+/**
+ * Returns the element of the reply with the given ID.
+ *
+ * @param replyId
+ *            The ID of the reply
+ * @returns The element of the reply
+ */
+function getReply(replyId) {
+       return $("#sone .reply#" + replyId);
+}
+
 function getReplyElement(element) {
        return $(element).closest(".reply");
 }
@@ -344,6 +383,50 @@ function getReplyAuthor(element) {
        return getReplyElement(element).find(".reply-author").text();
 }
 
+/**
+ * Returns the notification with the given ID.
+ *
+ * @param notificationId
+ *            The ID of the notification
+ * @returns The notification element
+ */
+function getNotification(notificationId) {
+       return $("#sone #notification-area .notification#" + notificationId);
+}
+
+/**
+ * Returns the notification element closest to the given element.
+ *
+ * @param element
+ *            The element to get the closest notification of
+ * @return The closest notification element
+ */
+function getNotificationElement(element) {
+       return $(element).closest(".notification");
+}
+
+/**
+ * Returns the ID of the notification element.
+ *
+ * @param notificationElement
+ *            The notification element
+ * @returns The ID of the notification
+ */
+function getNotificationId(notificationElement) {
+       return $(notificationElement).attr("id");
+}
+
+/**
+ * Returns the time the notification was last updated.
+ *
+ * @param notificationElement
+ *            The notification element
+ * @returns The last update time of the notification
+ */
+function getNotificationLastUpdatedTime(notificationElement) {
+       return $(notificationElement).attr("lastUpdatedTime");
+}
+
 function likePost(postId) {
        $.getJSON("like.ajax", { "type": "post", "post" : postId, "formPassword": getFormPassword() }, function(data, textStatus) {
                if ((data == null) || !data.success) {
@@ -476,6 +559,38 @@ function updateTrustControls(soneId, trustValue) {
        });
 }
 
+/**
+ * Bookmarks the post with the given ID.
+ *
+ * @param postId
+ *            The ID of the post to bookmark
+ */
+function bookmarkPost(postId) {
+       (function(postId) {
+               $.getJSON("bookmark.ajax", {"formPassword": getFormPassword(), "type": "post", "post": postId}, function(data, textStatus) {
+                       if ((data != null) && data.success) {
+                               getPost(postId).find(".bookmark").toggleClass("hidden", true);
+                               getPost(postId).find(".unbookmark").toggleClass("hidden", false);
+                       }
+               });
+       })(postId);
+}
+
+/**
+ * Unbookmarks the post with the given ID.
+ *
+ * @param postId
+ *            The ID of the post to unbookmark
+ */
+function unbookmarkPost(postId) {
+       $.getJSON("unbookmark.ajax", {"formPassword": getFormPassword(), "type": "post", "post": postId}, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       getPost(postId).find(".bookmark").toggleClass("hidden", false);
+                       getPost(postId).find(".unbookmark").toggleClass("hidden", true);
+               }
+       });
+}
+
 function updateReplyLikes(replyId) {
        $.getJSON("getLikes.ajax", { "type": "reply", "reply": replyId }, function(data, textStatus) {
                if ((data != null) && data.success) {
@@ -518,25 +633,6 @@ function postReply(sender, postId, text, callbackFunction) {
 }
 
 /**
- * Requests information about the reply with the given ID.
- *
- * @param replyId
- *            The ID of the reply
- * @param callbackFunction
- *            A callback function (parameters soneId, soneName, replyTime,
- *            replyDisplayTime, text, html)
- */
-function getReply(replyId, callbackFunction) {
-       $.getJSON("getReply.ajax", { "reply" : replyId }, function(data, textStatus) {
-               if ((data != null) && data.success) {
-                       callbackFunction(data.soneId, data.soneName, data.time, data.displayTime, data.text, data.html);
-               }
-       }, function(xmlHttpRequest, textStatus, error) {
-               /* ignore error. */
-       });
-}
-
-/**
  * Ajaxifies the given Sone by enhancing all eligible elements with AJAX.
  *
  * @param soneElement
@@ -582,7 +678,7 @@ function ajaxifySone(soneElement) {
 
        /* mark Sone as known when clicking it. */
        $(soneElement).click(function() {
-               markSoneAsKnown(soneElement);
+               markSoneAsKnown(this);
        });
 }
 
@@ -597,6 +693,8 @@ function ajaxifyPost(postElement) {
                return false;
        });
        $(postElement).find(".create-reply button:submit").click(function() {
+               button = $(this);
+               button.attr("disabled", "disabled");
                sender = $(this.form).find(":input[name=sender]").val();
                inputField = $(this.form).find(":input[name=text]:enabled").get(0);
                postId = getPostId(this);
@@ -606,7 +704,6 @@ function ajaxifyPost(postElement) {
                                if (success) {
                                        $(inputField).val("");
                                        loadNewReply(replyId, soneId, postId);
-                                       markPostAsKnown(getPostElement(inputField));
                                        $("#sone .post#" + postId + " .create-reply").addClass("hidden");
                                        $("#sone .post#" + postId + " .create-reply .sender").hide();
                                        $("#sone .post#" + postId + " .create-reply .select-sender").show();
@@ -614,6 +711,7 @@ function ajaxifyPost(postElement) {
                                } else {
                                        alert(error);
                                }
+                               button.removeAttr("disabled");
                        });
                })(sender, postId, text, inputField);
                return false;
@@ -630,12 +728,10 @@ function ajaxifyPost(postElement) {
        /* convert all “like” buttons to javascript functions. */
        $(postElement).find(".like-post").submit(function() {
                likePost(getPostId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
        $(postElement).find(".unlike-post").submit(function() {
                unlikePost(getPostId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
 
@@ -653,13 +749,35 @@ function ajaxifyPost(postElement) {
                return false;
        });
 
+       /* convert bookmark/unbookmark buttons to javascript functions. */
+       $(postElement).find(".bookmark").submit(function() {
+               bookmarkPost(getPostId(this));
+               return false;
+       });
+       $(postElement).find(".unbookmark").submit(function() {
+               unbookmarkPost(getPostId(this));
+               return false;
+       });
+
+       /* convert “show source” link into javascript function. */
+       $(postElement).find(".show-source").each(function() {
+               $("a", this).click(function() {
+                       $(".post-text.text", getPostElement(this)).toggleClass("hidden");
+                       $(".post-text.raw-text", getPostElement(this)).toggleClass("hidden");
+                       return false;
+               });
+       });
+
        /* add “comment” link. */
-       addCommentLink(getPostId(postElement), postElement, $(postElement).find(".post-status-line .time"));
+       addCommentLink(getPostId(postElement), getPostAuthor(postElement), postElement, $(postElement).find(".post-status-line .permalink-author"));
 
        /* process all replies. */
+       replyIds = [];
        $(postElement).find(".reply").each(function() {
+               replyIds.push(getReplyId(this));
                ajaxifyReply(this);
        });
+       updateReplyTimes(replyIds.join(","));
 
        /* process reply input fields. */
        getTranslation("WebInterface.DefaultText.Reply", function(text) {
@@ -698,12 +816,10 @@ function ajaxifyPost(postElement) {
 function ajaxifyReply(replyElement) {
        $(replyElement).find(".like-reply").submit(function() {
                likeReply(getReplyId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
        $(replyElement).find(".unlike-reply").submit(function() {
                unlikeReply(getReplyId(this));
-               markPostAsKnown(getPostElement(this));
                return false;
        });
        (function(replyElement) {
@@ -713,7 +829,16 @@ function ajaxifyReply(replyElement) {
                        });
                });
        })(replyElement);
-       addCommentLink(getPostId(replyElement), replyElement, $(replyElement).find(".reply-status-line .time"));
+       addCommentLink(getPostId(replyElement), getReplyAuthor(replyElement), replyElement, $(replyElement).find(".reply-status-line .permalink-author"));
+
+       /* convert “show source” link into javascript function. */
+       $(replyElement).find(".show-reply-source").each(function() {
+               $("a", this).click(function() {
+                       $(".reply-text.text", getReplyElement(this)).toggleClass("hidden");
+                       $(".reply-text.raw-text", getReplyElement(this)).toggleClass("hidden");
+                       return false;
+               });
+       });
 
        /* convert trust control buttons to javascript functions. */
        $(replyElement).find(".reply-trust").submit(function() {
@@ -728,11 +853,6 @@ function ajaxifyReply(replyElement) {
                untrustSone(getReplyAuthor(this));
                return false;
        });
-
-       /* mark post and all replies as known on click. */
-       $(replyElement).click(function() {
-               markPostAsKnown(getPostElement(this));
-       });
 }
 
 /**
@@ -753,6 +873,15 @@ function ajaxifyNotification(notification) {
        notification.find("form.mark-as-read button").click(function() {
                $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": $(":input[name=type]", this.form).val(), "id": $(":input[name=id]", this.form).val()});
        });
+       notification.find("a[class^='link-']").each(function() {
+               linkElement = $(this);
+               if (linkElement.is("[href^='viewPost']")) {
+                       id = linkElement.attr("class").substr(5);
+                       if (hasPost(id)) {
+                               linkElement.attr("href", "#post-" + id).addClass("in-page-link");
+                       }
+               }
+       });
        notification.find("form.dismiss button").click(function() {
                $.getJSON("dismissNotification.ajax", { "formPassword" : getFormPassword(), "notification" : notification.attr("id") }, function(data, textStatus) {
                        /* dismiss in case of error, too. */
@@ -764,33 +893,144 @@ function ajaxifyNotification(notification) {
        return notification;
 }
 
+/**
+ * Retrieves element IDs from notification elements.
+ *
+ * @param notification
+ *            The notification element
+ * @param selector
+ *            The selector of the element containing the ID as text
+ * @returns All extracted IDs
+ */
+function getElementIds(notification, selector) {
+       elementIds = [];
+       $(selector, notification).each(function() {
+               elementIds.push($(this).text());
+       });
+       return elementIds;
+}
+
+/**
+ * Compares the given notification elements and calls {@link #markSoneAsKnown()}
+ * for every ID that is contained in the old notification but not in the new.
+ *
+ * @param oldNotification
+ *            The old notification element
+ * @param newNotification
+ *            The new notification element
+ */
+function checkForRemovedSones(oldNotification, newNotification) {
+       if (getNotificationId(oldNotification) != "new-sone-notification") {
+               return;
+       }
+       oldIds = getElementIds(oldNotification, ".new-sone-id");
+       newIds = getElementIds(newNotification, ".new-sone-id");
+       $.each(oldIds, function(index, value) {
+               if ($.inArray(value, newIds) == -1) {
+                       markSoneAsKnown(getSone(value), true);
+               }
+       });
+}
+
+/**
+ * Compares the given notification elements and calls {@link #markPostAsKnown()}
+ * for every ID that is contained in the old notification but not in the new.
+ *
+ * @param oldNotification
+ *            The old notification element
+ * @param newNotification
+ *            The new notification element
+ */
+function checkForRemovedPosts(oldNotification, newNotification) {
+       if (getNotificationId(oldNotification) != "new-post-notification") {
+               return;
+       }
+       oldIds = getElementIds(oldNotification, ".post-id");
+       newIds = getElementIds(newNotification, ".post-id");
+       $.each(oldIds, function(index, value) {
+               if ($.inArray(value, newIds) == -1) {
+                       markPostAsKnown(getPost(value), true);
+               }
+       });
+}
+
+/**
+ * Compares the given notification elements and calls
+ * {@link #markReplyAsKnown()} for every ID that is contained in the old
+ * notification but not in the new.
+ *
+ * @param oldNotification
+ *            The old notification element
+ * @param newNotification
+ *            The new notification element
+ */
+function checkForRemovedReplies(oldNotification, newNotification) {
+       if (getNotificationId(oldNotification) != "new-replies-notification") {
+               return;
+       }
+       oldIds = getElementIds(oldNotification, ".reply-id");
+       newIds = getElementIds(newNotification, ".reply-id");
+       $.each(oldIds, function(index, value) {
+               if ($.inArray(value, newIds) == -1) {
+                       markReplyAsKnown(getReply(value), true);
+               }
+       });
+}
+
 function getStatus() {
-       $.getJSON("getStatus.ajax", {"loadAllSones": isKnownSonesPage()}, function(data, textStatus) {
+       $.getJSON("getStatus.ajax", isViewSonePage() ? {"soneIds": getShownSoneId() } : {"loadAllSones": isKnownSonesPage()}, function(data, textStatus) {
                if ((data != null) && data.success) {
                        /* process Sone information. */
                        $.each(data.sones, function(index, value) {
-                               updateSoneStatus(value.id, value.name, value.status, value.modified, value.locked, value.lastUpdatedUnknown ? null : value.lastUpdated);
+                               updateSoneStatus(value.id, value.name, value.status, value.modified, value.locked, value.lastUpdatedUnknown ? null : value.lastUpdated, value.lastUpdatedText);
                        });
-                       /* process notifications. */
-                       $.each(data.notifications, function(index, value) {
-                               oldNotification = $("#sone #notification-area .notification#" + value.id);
-                               notification = ajaxifyNotification(createNotification(value.id, value.text, value.dismissable)).hide();
-                               if (oldNotification.length != 0) {
-                                       if ((oldNotification.find(".short-text").length > 0) && (notification.find(".short-text").length > 0)) {
-                                               opened = oldNotification.is(":visible") && oldNotification.find(".short-text").hasClass("hidden");
-                                               notification.find(".short-text").toggleClass("hidden", opened);
-                                               notification.find(".text").toggleClass("hidden", !opened);
+                       /* search for removed notifications. */
+                       $("#sone #notification-area .notification").each(function() {
+                               notificationId = $(this).attr("id");
+                               foundNotification = false;
+                               $.each(data.notifications, function(index, value) {
+                                       if (value.id == notificationId) {
+                                               foundNotification = true;
+                                               return false;
                                        }
-                                       oldNotification.replaceWith(notification.show());
-                               } else {
-                                       $("#sone #notification-area").append(notification);
-                                       notification.slideDown();
+                               });
+                               if (!foundNotification) {
+                                       if (notificationId == "new-sone-notification") {
+                                               $(".new-sone-id", this).each(function(index, element) {
+                                                       soneId = $(this).text();
+                                                       markSoneAsKnown(getSone(soneId), true);
+                                               });
+                                       } else if (notificationId == "new-post-notification") {
+                                               $(".post-id", this).each(function(index, element) {
+                                                       postId = $(this).text();
+                                                       markPostAsKnown(getPost(postId), true);
+                                               });
+                                       } else if (notificationId == "new-replies-notification") {
+                                               $(".reply-id", this).each(function(index, element) {
+                                                       replyId = $(this).text();
+                                                       markReplyAsKnown(getReply(replyId), true);
+                                               });
+                                       }
+                                       $(this).slideUp("normal", function() {
+                                               $(this).remove();
+                                               /* remove activity when no notifications are visible. */
+                                               if ($("#sone #notification-area .notification").length == 0) {
+                                                       resetActivity();
+                                               }
+                                       });
                                }
-                               setActivity();
                        });
-                       $.each(data.removedNotifications, function(index, value) {
-                               $("#sone #notification-area .notification#" + value.id).slideUp();
+                       /* process notifications. */
+                       notificationIds = [];
+                       $.each(data.notifications, function(index, value) {
+                               oldNotification = getNotification(value.id);
+                               if ((oldNotification.length == 0) || (value.lastUpdatedTime > getNotificationLastUpdatedTime(oldNotification))) {
+                                       notificationIds.push(value.id);
+                               }
                        });
+                       if (notificationIds.length > 0) {
+                               loadNotifications(notificationIds);
+                       }
                        /* process new posts. */
                        $.each(data.newPosts, function(index, value) {
                                loadNewPost(value.id, value.sone, value.recipient, value.time);
@@ -812,6 +1052,40 @@ function getStatus() {
 }
 
 /**
+ * Requests multiple notifications from Sone and displays them.
+ *
+ * @param notificationIds
+ *            Array of IDs of the notifications to load
+ */
+function loadNotifications(notificationIds) {
+       $.getJSON("getNotification.ajax", {"notifications": notificationIds.join(",")}, function(data, textStatus) {
+               if (!data || !data.success) {
+                       // TODO - show error
+                       return;
+               }
+               $.each(data.notifications, function(index, value) {
+                       oldNotification = getNotification(value.id);
+                       notification = ajaxifyNotification(createNotification(value.id, value.lastUpdatedTime, value.text, value.dismissable)).hide();
+                       if (oldNotification.length != 0) {
+                               if ((oldNotification.find(".short-text").length > 0) && (notification.find(".short-text").length > 0)) {
+                                       opened = oldNotification.is(":visible") && oldNotification.find(".short-text").hasClass("hidden");
+                                       notification.find(".short-text").toggleClass("hidden", opened);
+                                       notification.find(".text").toggleClass("hidden", !opened);
+                               }
+                               checkForRemovedSones(oldNotification, notification);
+                               checkForRemovedPosts(oldNotification, notification);
+                               checkForRemovedReplies(oldNotification, notification);
+                               oldNotification.replaceWith(notification.show());
+                       } else {
+                               $("#sone #notification-area").append(notification);
+                               notification.slideDown();
+                               setActivity();
+                       }
+               })
+       });
+}
+
+/**
  * Returns the ID of the currently logged in Sone.
  *
  * @return The ID of the current Sone, or an empty string if no Sone is logged
@@ -841,6 +1115,22 @@ function isIndexPage() {
 }
 
 /**
+ * Returns the current page of the selected pagination. If no pagination can be
+ * found with the given selector, {@code 1} is returned.
+ *
+ * @param paginationSelector
+ *            The pagination selector
+ * @returns The current page of the pagination
+ */
+function getPage(paginationSelector) {
+       pagination = $(paginationSelector);
+       if (pagination.length > 0) {
+               return $(".current-page", paginationSelector).text();
+       }
+       return 1;
+}
+
+/**
  * Returns whether the current page is a “view Sone” page.
  *
  * @returns {Boolean} <code>true</code> if the current page is a “view Sone”
@@ -918,7 +1208,7 @@ function loadNewPost(postId, soneId, recipientId, time) {
        if (hasPost(postId)) {
                return;
        }
-       if (!isIndexPage()) {
+       if (!isIndexPage() || (getPage(".pagination-index") > 1)) {
                if (!isViewPostPage() || (getShownPostId() != postId)) {
                        if (!isViewSonePage() || ((getShownSoneId() != soneId) && (getShownSoneId() != recipientId))) {
                                return;
@@ -933,7 +1223,7 @@ function loadNewPost(postId, soneId, recipientId, time) {
                        if (hasPost(data.post.id)) {
                                return;
                        }
-                       if (!isIndexPage() && !(isViewSonePage() && ((getShownSoneId() == data.post.sone) || (getShownSoneId() == data.post.recipient)))) {
+                       if ((!isIndexPage() || (getPage(".pagination-index") > 1)) && !(isViewSonePage() && ((getShownSoneId() == data.post.sone) || (getShownSoneId() == data.post.recipient)))) {
                                return;
                        }
                        var firstOlderPost = null;
@@ -948,6 +1238,7 @@ function loadNewPost(postId, soneId, recipientId, time) {
                                newPost.insertBefore(firstOlderPost);
                        }
                        ajaxifyPost(newPost);
+                       updatePostTimes(data.post.id);
                        newPost.slideDown();
                        setActivity();
                }
@@ -986,6 +1277,7 @@ function loadNewReply(replyId, soneId, postId, postSoneId) {
                                        }
                                }
                                ajaxifyReply(newReply);
+                               updateReplyTimes(data.reply.id);
                                newReply.slideDown();
                                setActivity();
                                return false;
@@ -999,60 +1291,196 @@ function loadNewReply(replyId, soneId, postId, postSoneId) {
  *
  * @param soneElement
  *            The Sone to mark as known
+ * @param skipRequest
+ *            true to skip the JSON request, false or omit to perform the JSON
+ *            request
  */
-function markSoneAsKnown(soneElement) {
-       if ($(".new", soneElement).length > 0) {
-               $.getJSON("maskAsKnown.ajax", {"formPassword": getFormPassword(), "type": "sone", "id": getSoneId(soneElement)}, function(data, textStatus) {
-                       $(soneElement).removeClass("new");
-               });
+function markSoneAsKnown(soneElement, skipRequest) {
+       if ($(soneElement).is(".new")) {
+               $(soneElement).removeClass("new");
+               if ((typeof skipRequest == "undefined") || !skipRequest) {
+                       $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "sone", "id": getSoneId(soneElement)});
+               }
        }
 }
 
-function markPostAsKnown(postElements) {
+function markPostAsKnown(postElements, skipRequest) {
        $(postElements).each(function() {
                postElement = this;
                if ($(postElement).hasClass("new")) {
                        (function(postElement) {
-                               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "post", "id": getPostId(postElement)}, function(data, textStatus) {
-                                       $(postElement).removeClass("new");
-                                       $(".click-to-show", postElement).removeClass("new");
-                               });
+                               $(postElement).removeClass("new");
+                               $(".click-to-show", postElement).removeClass("new");
+                               if ((typeof skipRequest == "undefined") || !skipRequest) {
+                                       $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "post", "id": getPostId(postElement)});
+                               }
                        })(postElement);
                }
        });
        markReplyAsKnown($(postElements).find(".reply"));
 }
 
-function markReplyAsKnown(replyElements) {
+function markReplyAsKnown(replyElements, skipRequest) {
        $(replyElements).each(function() {
                replyElement = this;
                if ($(replyElement).hasClass("new")) {
                        (function(replyElement) {
-                               $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "reply", "id": getReplyId(replyElement)}, function(data, textStatus) {
-                                       $(replyElement).removeClass("new");
-                               });
+                               $(replyElement).removeClass("new");
+                               if ((typeof skipRequest == "undefined") || !skipRequest) {
+                                       $.getJSON("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "reply", "id": getReplyId(replyElement)});
+                               }
                        })(replyElement);
                }
        });
 }
 
+/**
+ * Updates the time of the post with the given ID.
+ *
+ * @param postId
+ *            The ID of the post to update
+ * @param timeText
+ *            The text of the time to show
+ * @param refreshTime
+ *            The refresh time after which to request a new time (in seconds)
+ * @param tooltip
+ *            The tooltip to show
+ */
+function updatePostTime(postId, timeText, refreshTime, tooltip) {
+       if (!getPost(postId).is(":visible")) {
+               return;
+       }
+       getPost(postId).find(".post-status-line > .time a").html(timeText).attr("title", tooltip);
+       (function(postId, refreshTime) {
+               setTimeout(function() {
+                       updatePostTimes(postId);
+               }, refreshTime * 1000);
+       })(postId, refreshTime);
+}
+
+/**
+ * Requests new rendered times for the posts with the given IDs.
+ *
+ * @param postIds
+ *            Comma-separated post IDs
+ */
+function updatePostTimes(postIds) {
+       $.getJSON("getTimes.ajax", { "posts" : postIds }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       $.each(data.postTimes, function(index, value) {
+                               updatePostTime(index, value.timeText, value.refreshTime, value.tooltip);
+                       });
+               }
+       });
+}
+
+/**
+ * Updates the time of the reply with the given ID.
+ *
+ * @param postId
+ *            The ID of the reply to update
+ * @param timeText
+ *            The text of the time to show
+ * @param refreshTime
+ *            The refresh time after which to request a new time (in seconds)
+ * @param tooltip
+ *            The tooltip to show
+ */
+function updateReplyTime(replyId, timeText, refreshTime, tooltip) {
+       getReply(replyId).find(".reply-status-line > .time").html(timeText).attr("title", tooltip);
+       (function(replyId, refreshTime) {
+               setTimeout(function() {
+                       updateReplyTimes(replyId);
+               }, refreshTime * 1000);
+       })(replyId, refreshTime);
+}
+
+/**
+ * Requests new rendered times for the posts with the given IDs.
+ *
+ * @param postIds
+ *            Comma-separated post IDs
+ */
+function updateReplyTimes(replyIds) {
+       $.getJSON("getTimes.ajax", { "replies" : replyIds }, function(data, textStatus) {
+               if ((data != null) && data.success) {
+                       $.each(data.replyTimes, function(index, value) {
+                               updateReplyTime(index, value.timeText, value.refreshTime, value.tooltip);
+                       });
+               }
+       });
+}
+
 function resetActivity() {
        title = document.title;
        if (title.indexOf('(') == 0) {
-               document.title = title.substr(title.indexOf(' ') + 1);
+               setTitle(title.substr(title.indexOf(' ') + 1));
        }
+       iconBlinking = false;
 }
 
 function setActivity() {
        if (!focus) {
                title = document.title;
                if (title.indexOf('(') != 0) {
-                       document.title = "(!) " + title;
+                       setTitle("(!) " + title);
+               }
+               if (!iconBlinking) {
+                       setTimeout(toggleIcon, 1500);
+                       iconBlinking = true;
                }
        }
 }
 
 /**
+ * Sets the window title after a small delay to prevent race-condition issues.
+ *
+ * @param title
+ *            The title to set
+ */
+function setTitle(title) {
+       setTimeout(function() {
+               document.title = title;
+       }, 50);
+}
+
+/** Whether the icon is currently showing activity. */
+var iconActive = false;
+
+/** Whether the icon is currently supposed to blink. */
+var iconBlinking = false;
+
+/**
+ * Toggles the icon. If the window has gained focus and the icon is still
+ * showing the activity state, it is returned to normal.
+ */
+function toggleIcon() {
+       if (focus || !iconBlinking) {
+               if (iconActive) {
+                       changeIcon("images/icon.png");
+                       iconActive = false;
+               }
+               iconBlinking = false;
+       } else {
+               iconActive = !iconActive;
+               changeIcon(iconActive ? "images/icon-activity.png" : "images/icon.png");
+               setTimeout(toggleIcon, 1500);
+       }
+}
+
+/**
+ * Changes the icon of the page.
+ *
+ * @param iconUrl
+ *            The new URL of the icon
+ */
+function changeIcon(iconUrl) {
+       $("link[rel=icon]").remove();
+       $("head").append($("<link>").attr("rel", "icon").attr("type", "image/png").attr("href", iconUrl));
+       $("iframe[id=icon-update]")[0].src += "";
+}
+
+/**
  * Creates a new notification.
  *
  * @param id
@@ -1063,8 +1491,8 @@ function setActivity() {
  *            <code>true</code> if the notification can be dismissed by the
  *            user
  */
-function createNotification(id, text, dismissable) {
-       notification = $("<div></div>").addClass("notification").attr("id", id);
+function createNotification(id, lastUpdatedTime, text, dismissable) {
+       notification = $("<div></div>").addClass("notification").attr("id", id).attr("lastUpdatedTime", lastUpdatedTime);
        if (dismissable) {
                dismissForm = $("#sone #notification-area #notification-dismiss-template").clone().removeClass("hidden").removeAttr("id")
                dismissForm.find("input[name=notification]").val(id);
@@ -1178,15 +1606,15 @@ $(document).ready(function() {
                        return false;
                });
                $("#sone #update-status").submit(function() {
+                       button = $("button:submit", this);
+                       button.attr("disabled", "disabled");
                        if ($(this).find(":input.default:enabled").length > 0) {
                                return false;
                        }
                        sender = $(this).find(":input[name=sender]").val();
                        text = $(this).find(":input[name=text]:enabled").val();
                        $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "sender": sender, "text": text }, function(data, textStatus) {
-                               if ((data != null) && data.success) {
-                                       loadNewPost(data.postId, data.sone, data.recipient);
-                               }
+                               button.removeAttr("disabled");
                        });
                        $(this).find(":input[name=sender]").val(getCurrentSoneId());
                        $(this).find(":input[name=text]:enabled").val("").blur();
@@ -1196,17 +1624,29 @@ $(document).ready(function() {
                });
        });
 
+       /* ajaxify the search input field. */
+       getTranslation("WebInterface.DefaultText.Search", function(defaultText) {
+               registerInputTextareaSwap("#sone #search input[name=query]", defaultText, "query", false, true);
+       });
+
        /* ajaxify input field on “view Sone” page. */
        getTranslation("WebInterface.DefaultText.Message", function(defaultText) {
                registerInputTextareaSwap("#sone #post-message input[name=text]", defaultText, "text", false, false);
+               $("#sone #post-message .select-sender").css("display", "inline");
+               $("#sone #post-message .sender").hide();
+               $("#sone #post-message .select-sender button").click(function() {
+                       $("#sone #post-message .sender").show();
+                       $("#sone #post-message .select-sender").hide();
+                       return false;
+               });
                $("#sone #post-message").submit(function() {
-                       text = $(this).find(":input:enabled").val();
-                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "recipient": getShownSoneId(), "text": text }, function(data, textStatus) {
-                               if ((data != null) && data.success) {
-                                       loadNewPost(data.postId, getCurrentSoneId());
-                               }
-                       });
-                       $(this).find(":input:enabled").val("").blur();
+                       sender = $(this).find(":input[name=sender]").val();
+                       text = $(this).find(":input[name=text]:enabled").val();
+                       $.getJSON("createPost.ajax", { "formPassword": getFormPassword(), "recipient": getShownSoneId(), "sender": sender, "text": text });
+                       $(this).find(":input[name=sender]").val(getCurrentSoneId());
+                       $(this).find(":input[name=text]:enabled").val("").blur();
+                       $(this).find(".sender").hide();
+                       $(this).find(".select-sender").show();
                        return false;
                });
        });
@@ -1223,6 +1663,13 @@ $(document).ready(function() {
                });
        });
 
+       /* update post times. */
+       postIds = [];
+       $("#sone .post").each(function() {
+               postIds.push(getPostId(this));
+       });
+       updatePostTimes(postIds.join(","));
+
        /* hides all replies but the latest two. */
        if (!isViewPostPage()) {
                getTranslation("WebInterface.ClickToShow.Replies", function(text) {
@@ -1260,6 +1707,11 @@ $(document).ready(function() {
                ajaxifyNotification($(this));
        });
 
+       /* disable all permalinks. */
+       $(".permalink").click(function() {
+               return false;
+       });
+
        /* activate status polling. */
        setTimeout(getStatus, 5000);