+ * 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
+ * in
+ */
+function getCurrentSoneId() {
+ return $("#currentSoneId").text();
+}
+
+/**
+ * Returns the content of the page-id attribute.
+ *
+ * @returns String The page ID
+ */
+function getPageId() {
+ return sone.find(".page-id").text();
+}
+
+/**
+ * Returns whether the current page is the index page.
+ *
+ * @returns {Boolean} <code>true</code> if the current page is the index page,
+ * <code>false</code> otherwise
+ */
+function isIndexPage() {
+ return getPageId() === "index";
+}
+
+/**
+ * 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) {
+ const 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”
+ * page, <code>false</code> otherwise
+ */
+function isViewSonePage() {
+ return getPageId() === "view-sone";
+}
+
+/**
+ * Returns the ID of the currently shown Sone. This will only return a sensible
+ * value if isViewSonePage() returns <code>true</code>.
+ *
+ * @returns The ID of the currently shown Sone
+ */
+function getShownSoneId() {
+ return sone.find(".sone-id").first().text();
+}
+
+/**
+ * Returns the ID of all currently visible Sones. This is mainly used on the
+ * “Known Sones” page.
+ *
+ * @returns The ID of the currently shown Sones
+ */
+function getShownSoneIds() {
+ const soneIds = [];
+ sone.find("#known-sones .sone .id").each(function() {
+ soneIds.push($(this).text());
+ });
+ return soneIds.join(",");
+}
+
+/**
+ * Returns whether the current page is a “view post” page.
+ *
+ * @returns {Boolean} <code>true</code> if the current page is a “view post”
+ * page, <code>false</code> otherwise
+ */
+function isViewPostPage() {
+ return getPageId() === "view-post";
+}
+
+/**
+ * Returns the ID of the currently shown post. This will only return a sensible
+ * value if isViewPostPage() returns <code>true</code>.
+ *
+ * @returns The ID of the currently shown post
+ */
+function getShownPostId() {
+ return sone.find(".post-id").text();
+}
+
+/**
+ * Returns whether the current page is the “known Sones” page.
+ *
+ * @returns {Boolean} <code>true</code> if the current page is the “known
+ * Sones” page, <code>false</code> otherwise
+ */
+function isKnownSonesPage() {
+ return getPageId() === "known-sones";
+}
+
+/**
+ * Returns whether a post with the given ID exists on the current page.
+ *
+ * @param postId
+ * The post ID to check for
+ * @returns {Boolean} <code>true</code> if a post with the given ID already
+ * exists on the page, <code>false</code> otherwise
+ */
+function hasPost(postId) {
+ return $(".post#post-" + postId).length > 0;
+}
+
+/**
+ * Returns whether a reply with the given ID exists on the current page.
+ *
+ * @param replyId
+ * The reply ID to check for
+ * @returns {Boolean} <code>true</code> if a reply with the given ID already
+ * exists on the page, <code>false</code> otherwise
+ */
+function hasReply(replyId) {
+ return sone.find(".reply#reply-" + replyId).length > 0;
+}
+
+function loadNewPost(postId, soneId, recipientId, time) {
+ if (hasPost(postId)) {
+ return;
+ }
+ if (!isIndexPage() || (getPage(".pagination-index") > 1)) {
+ if (!isViewPostPage() || (getShownPostId() !== postId)) {
+ if (!isViewSonePage() || ((getShownSoneId() !== soneId) && (getShownSoneId() !== recipientId)) || (getPage(".post-navigation") > 1)) {
+ return;
+ }
+ }
+ }
+ if (getPostTime(sone.find(".post").last()) > time) {
+ return;
+ }
+ ajaxGet("getPost.ajax", { "post" : postId }, function(data) {
+ if ((data != null) && data.success) {
+ if (hasPost(data.post.id)) {
+ return;
+ }
+ if ((!isIndexPage() || (getPage(".pagination-index") > 1)) && !(isViewSonePage() && ((getShownSoneId() === data.post.sone) || (getShownSoneId() === data.post.recipient) || (getPage(".post-navigation") > 1)))) {
+ return;
+ }
+ let firstOlderPost = null;
+ sone.find(".post").each(function() {
+ if (getPostTime(this) < data.post.time) {
+ firstOlderPost = $(this);
+ return false;
+ }
+ });
+ const newPost = $(data.post.html).addClass("hidden");
+ if ($(".post-author-local", newPost).text() === "true") {
+ newPost.removeClass("new");
+ }
+ if (firstOlderPost != null) {
+ newPost.insertBefore(firstOlderPost);
+ }
+ ajaxifyPost(newPost);
+ updatePostTimes(data.post.id);
+ newPost.slideDown();
+ setActivity();
+ }
+ });
+}
+
+function loadNewReply(replyId, soneId, postId) {
+ if (hasReply(replyId)) {
+ return;
+ }
+ if (!hasPost(postId)) {
+ return;
+ }
+ ajaxGet("getReply.ajax", { "reply": replyId }, function(data) {
+ /* find post. */
+ if ((data != null) && data.success) {
+ if (hasReply(data.reply.id)) {
+ return;
+ }
+ sone.find(".post#post-" + data.reply.postId).each(function() {
+ let firstNewerReply = null;
+ $(this).find(".replies .reply").each(function() {
+ if (getReplyTime(this) > data.reply.time) {
+ firstNewerReply = $(this);
+ return false;
+ }
+ });
+ const newReply = $(data.reply.html).addClass("hidden");
+ if ($(".reply-author-local", newReply).text() === "true") {
+ newReply.removeClass("new");
+ (function(newReply) {
+ setTimeout(function() {
+ markReplyAsKnown(newReply, false);
+ }, 5000);
+ })(newReply);
+ }
+ if (firstNewerReply != null) {
+ newReply.insertBefore(firstNewerReply);
+ } else {
+ if ($(this).find(".replies .create-reply")) {
+ $(this).find(".replies .create-reply").before(newReply);
+ } else {
+ $(this).find(".replies").append(newReply);
+ }
+ }
+ ajaxifyReply(newReply);
+ updateReplyTimes(data.reply.id);
+ newReply.slideDown();
+ setActivity();
+ return false;
+ });
+ }
+ });
+}
+
+function loadLinkedElements(links) {
+ const failedElements = links.filter(function(element) {
+ return element.failed;
+ });
+ if (failedElements.length > 0) {
+ failedElements.forEach(function(element) {
+ getLinkedElements(element.link).each(function() {
+ $(this).remove()
+ });
+ });
+ }
+ const loadedElements = links.filter(function(element) {
+ return !element.loading && !element.failed;
+ });
+ if (loadedElements.length > 0) {
+ ajaxGet("getLinkedElement.ajax", {
+ "elements": JSON.stringify(loadedElements.map(function(element) {
+ return element.link;
+ }))
+ }, function (data) {
+ if ((data != null) && (data.success)) {
+ data.linkedElements.forEach(function (linkedElement) {
+ getLinkedElements(linkedElement.link).each(function() {
+ $(this).replaceWith(linkedElement.html);
+ });
+ });
+ }
+ });
+ }
+}
+
+function getLinkedElements(link) {
+ return $(".linked-element[title='" + link + "']")
+}
+
+/**
+ * Marks the given Sone as known if it is still new.
+ *
+ * @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, skipRequest) {
+ if ($(soneElement).hasClass("new")) {
+ $(soneElement).removeClass("new");
+ if ((typeof skipRequest == "undefined") || !skipRequest) {
+ ajaxGet("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "sone", "id": getSoneId(soneElement)});
+ requestNotifications();
+ }
+ }
+}
+
+function markPostAsKnown(postElements, skipRequest) {
+ $(postElements).each(function() {
+ const postElement = this;
+ if ($(postElement).hasClass("new") || ((typeof skipRequest != "undefined"))) {
+ (function(postElement) {
+ $(postElement).removeClass("new");
+ if ((typeof skipRequest == "undefined") || !skipRequest) {
+ ajaxGet("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "post", "id": getPostId(postElement)});
+ requestNotifications();
+ }
+ })(postElement);
+ }
+ $(".click-to-show", postElement).removeClass("new");
+ });
+ markReplyAsKnown($(postElements).find(".reply"), true);
+}
+
+function markReplyAsKnown(replyElements, skipRequest) {
+ $(replyElements).each(function() {
+ const replyElement = this;
+ if ($(replyElement).hasClass("new") || ((typeof skipRequest != "undefined"))) {
+ (function(replyElement) {
+ $(replyElement).removeClass("new");
+ if ((typeof skipRequest == "undefined") || !skipRequest) {
+ ajaxGet("markAsKnown.ajax", {"formPassword": getFormPassword(), "type": "reply", "id": getReplyId(replyElement)});
+ requestNotifications();
+ }
+ })(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).prop("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) {
+ if (postIds !== "") {
+ ajaxGet("getTimes.ajax", {"posts": postIds}, function (data) {
+ 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 replyId
+ * 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).prop("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 replyIds
+ * Comma-separated post IDs
+ */
+function updateReplyTimes(replyIds) {
+ if (replyIds !== "") {
+ ajaxGet("getTimes.ajax", {"replies": replyIds}, function (data) {
+ if ((data != null) && data.success) {
+ $.each(data.replyTimes, function (index, value) {
+ updateReplyTime(index, value.timeText, value.refreshTime, value.tooltip);
+ });
+ }
+ });
+ }
+}
+
+function resetActivity() {
+ const title = document.title;
+ if (title.indexOf('(') === 0) {
+ setTitle(title.substr(title.indexOf(' ') + 1));
+ }
+ iconBlinking = false;
+}
+
+function setActivity() {
+ if (!focus) {
+ const title = document.title;
+ if (title.indexOf('(') !== 0) {
+ 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. */
+let iconActive = false;
+
+/** Whether the icon is currently supposed to blink. */
+let 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>").prop("rel", "icon").prop("type", "image/png").prop("href", iconUrl));
+ $("iframe[id=icon-update]")[0].src += "";
+}
+
+/**