From e42a8f16b2ed6c3650cb288abbf48932ef63d97b Mon Sep 17 00:00:00 2001 From: =?utf8?q?David=20=E2=80=98Bombe=E2=80=99=20Roden?= Date: Wed, 6 Apr 2011 07:28:51 +0200 Subject: [PATCH] Add relative and dynamic timestamps to the web interface. This fixes #2. --- .../net/pterodactylus/sone/web/WebInterface.java | 2 + .../sone/web/ajax/GetTimesAjaxPage.java | 244 +++++++++++++++++++++ src/main/resources/i18n/sone.en.properties | 17 ++ src/main/resources/static/javascript/sone.js | 92 ++++++++ 4 files changed, 355 insertions(+) create mode 100644 src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java diff --git a/src/main/java/net/pterodactylus/sone/web/WebInterface.java b/src/main/java/net/pterodactylus/sone/web/WebInterface.java index acfdc7f..7c33803 100644 --- a/src/main/java/net/pterodactylus/sone/web/WebInterface.java +++ b/src/main/java/net/pterodactylus/sone/web/WebInterface.java @@ -72,6 +72,7 @@ import net.pterodactylus.sone.web.ajax.GetLikesAjaxPage; import net.pterodactylus.sone.web.ajax.GetPostAjaxPage; import net.pterodactylus.sone.web.ajax.GetReplyAjaxPage; import net.pterodactylus.sone.web.ajax.GetStatusAjaxPage; +import net.pterodactylus.sone.web.ajax.GetTimesAjaxPage; import net.pterodactylus.sone.web.ajax.GetTranslationPage; import net.pterodactylus.sone.web.ajax.LikeAjaxPage; import net.pterodactylus.sone.web.ajax.LockSoneAjaxPage; @@ -586,6 +587,7 @@ public class WebInterface implements CoreListener { pageToadlets.add(pageToadletFactory.createPageToadlet(new CreateReplyAjaxPage(this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new GetReplyAjaxPage(this, replyTemplate))); pageToadlets.add(pageToadletFactory.createPageToadlet(new GetPostAjaxPage(this, postTemplate))); + pageToadlets.add(pageToadletFactory.createPageToadlet(new GetTimesAjaxPage(this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new MarkAsKnownAjaxPage(this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new DeletePostAjaxPage(this))); pageToadlets.add(pageToadletFactory.createPageToadlet(new DeleteReplyAjaxPage(this))); diff --git a/src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java b/src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java new file mode 100644 index 0000000..5711257 --- /dev/null +++ b/src/main/java/net/pterodactylus/sone/web/ajax/GetTimesAjaxPage.java @@ -0,0 +1,244 @@ +/* + * Sone - GetTimesAjaxPage.java - Copyright © 2010–2011 David Roden + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation, either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package net.pterodactylus.sone.web.ajax; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; + +import net.pterodactylus.sone.data.Post; +import net.pterodactylus.sone.data.Reply; +import net.pterodactylus.sone.web.WebInterface; +import net.pterodactylus.util.json.JsonObject; +import net.pterodactylus.util.number.Digits; + +/** + * Ajax page that returns a formatted, relative timestamp for replies or posts. + * + * @author David ‘Bombe’ Roden + */ +public class GetTimesAjaxPage extends JsonPage { + + /** Formatter for tooltips. */ + private static final DateFormat dateFormat = new SimpleDateFormat("MMM d, yyyy, HH:mm:ss"); + + /** + * Creates a new get times AJAX page. + * + * @param webInterface + * The Sone web interface + */ + public GetTimesAjaxPage(WebInterface webInterface) { + super("getTimes.ajax", webInterface); + } + + /** + * {@inheritDoc} + */ + @Override + protected JsonObject createJsonObject(Request request) { + long now = System.currentTimeMillis(); + String allIds = request.getHttpRequest().getParam("posts"); + JsonObject postTimes = new JsonObject(); + if (allIds.length() > 0) { + String[] ids = allIds.split(","); + for (String id : ids) { + Post post = webInterface.getCore().getPost(id, false); + if (post == null) { + continue; + } + long age = now - post.getTime(); + JsonObject postTime = new JsonObject(); + Time time = getTime(age); + postTime.put("timeText", time.getText()); + postTime.put("refreshTime", time.getRefresh() / Time.SECOND); + postTime.put("tooltip", dateFormat.format(new Date(post.getTime()))); + postTimes.put(id, postTime); + } + } + JsonObject replyTimes = new JsonObject(); + allIds = request.getHttpRequest().getParam("replies"); + if (allIds.length() > 0) { + String[] ids = allIds.split(","); + for (String id : ids) { + Reply reply = webInterface.getCore().getReply(id, false); + if (reply == null) { + continue; + } + long age = now - reply.getTime(); + JsonObject replyTime = new JsonObject(); + Time time = getTime(age); + replyTime.put("timeText", time.getText()); + replyTime.put("refreshTime", time.getRefresh() / Time.SECOND); + replyTime.put("tooltip", dateFormat.format(new Date(reply.getTime()))); + replyTimes.put(id, replyTime); + } + } + return createSuccessJsonObject().put("postTimes", postTimes).put("replyTimes", replyTimes); + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean needsFormPassword() { + return false; + } + + /** + * {@inheritDoc} + */ + @Override + protected boolean requiresLogin() { + return false; + } + + // + // PRIVATE METHODS + // + + /** + * Returns the formatted relative time for a given age. + * + * @param age + * The age to format (in milliseconds) + * @return The formatted age + */ + private Time getTime(long age) { + String text; + long refresh; + if (age < 0) { + text = webInterface.getL10n().getDefaultString("View.Time.InTheFuture"); + refresh = 5 * Time.MINUTE; + } else if (age < 30 * Time.SECOND) { + text = webInterface.getL10n().getDefaultString("View.Time.AFewSecondsAgo"); + refresh = 5 * Time.SECOND; + } else if (age < 1 * Time.MINUTE) { + text = webInterface.getL10n().getString("View.Time.XSecondsAgo", "sec", String.valueOf((int) Digits.round(age / Time.SECOND, 10))); + refresh = 10 * Time.SECOND; + } else if (age < 2 * Time.MINUTE) { + text = webInterface.getL10n().getString("View.Time.AMinuteAgo"); + refresh = Time.MINUTE; + } else if (age < 30 * Time.MINUTE) { + text = webInterface.getL10n().getString("View.Time.XMinutesAgo", "min", String.valueOf((int) Digits.round(age / Time.MINUTE, 1))); + refresh = Time.MINUTE; + } else if (age < 45 * Time.MINUTE) { + text = webInterface.getL10n().getString("View.Time.HalfAnHourAgo"); + refresh = 10 * Time.MINUTE; + } else if (age < 2 * Time.HOUR) { + text = webInterface.getL10n().getString("View.Time.AnHourAgo"); + refresh = Time.HOUR; + } else if (age < 21 * Time.HOUR) { + text = webInterface.getL10n().getString("View.Time.XHoursAgo", "hour", String.valueOf((int) Digits.round(age / Time.HOUR, 1))); + refresh = Time.HOUR; + } else if (age < 42 * Time.HOUR) { + text = webInterface.getL10n().getString("View.Time.ADayAgo"); + refresh = Time.DAY; + } else if (age < 6 * Time.DAY) { + text = webInterface.getL10n().getString("View.Time.XDaysAgo", "day", String.valueOf((int) Digits.round(age / Time.DAY, 1))); + refresh = Time.DAY; + } else if (age < 11 * Time.DAY) { + text = webInterface.getL10n().getString("View.Time.AWeekAgo"); + refresh = Time.DAY; + } else if (age < 4 * Time.WEEK) { + text = webInterface.getL10n().getString("View.Time.XWeeksAgo", "week", String.valueOf((int) Digits.round(age / Time.WEEK, 1))); + refresh = Time.DAY; + } else if (age < 6 * Time.WEEK) { + text = webInterface.getL10n().getString("View.Time.AMonthAgo"); + refresh = Time.DAY; + } else if (age < 11 * Time.MONTH) { + text = webInterface.getL10n().getString("View.Time.XMonthsAgo", "month", String.valueOf((int) Digits.round(age / Time.MONTH, 1))); + refresh = Time.DAY; + } else if (age < 18 * Time.MONTH) { + text = webInterface.getL10n().getString("View.Time.AYearAgo"); + refresh = Time.WEEK; + } else { + text = webInterface.getL10n().getString("View.Time.XYearsAgo", "year", String.valueOf((int) Digits.round(age / Time.YEAR, 1))); + refresh = Time.WEEK; + } + return new Time(text, refresh); + } + + /** + * Container for a formatted time. + * + * @author David ‘Bombe’ Roden + */ + private static class Time { + + /** Number of milliseconds in a second. */ + private static final long SECOND = 1000; + + /** Number of milliseconds in a minute. */ + private static final long MINUTE = 60 * SECOND; + + /** Number of milliseconds in an hour. */ + private static final long HOUR = 60 * MINUTE; + + /** Number of milliseconds in a day. */ + private static final long DAY = 24 * HOUR; + + /** Number of milliseconds in a week. */ + private static final long WEEK = 7 * DAY; + + /** Number of milliseconds in a 30-day month. */ + private static final long MONTH = 30 * DAY; + + /** Number of milliseconds in a year. */ + private static final long YEAR = 365 * DAY; + + /** The formatted time. */ + private final String text; + + /** The time after which to refresh the time. */ + private final long refresh; + + /** + * Creates a new formatted time container. + * + * @param text + * The formatted time + * @param refresh + * The time after which to refresh the time (in milliseconds) + */ + public Time(String text, long refresh) { + this.text = text; + this.refresh = refresh; + } + + /** + * Returns the formatted time. + * + * @return The formatted time + */ + public String getText() { + return text; + } + + /** + * Returns the time after which to refresh the time. + * + * @return The time after which to refresh the time (in milliseconds) + */ + public long getRefresh() { + return refresh; + } + + } + +} diff --git a/src/main/resources/i18n/sone.en.properties b/src/main/resources/i18n/sone.en.properties index e8d36a0..b8a8570 100644 --- a/src/main/resources/i18n/sone.en.properties +++ b/src/main/resources/i18n/sone.en.properties @@ -238,6 +238,23 @@ View.Trust.Tooltip.Trust=Trust this person View.Trust.Tooltip.Distrust=Assign negative trust to this person View.Trust.Tooltip.Untrust=Remove your trust assignment for this person +View.Time.InTheFuture=in the future +View.Time.AFewSecondsAgo=a few seconds ago +View.Time.XSecondsAgo=${sec} seconds ago +View.Time.AMinuteAgo=about a minute ago +View.Time.XMinutesAgo=${min} minutes ago +View.Time.HalfAnHourAgo=half an hour ago +View.Time.AnHourAgo=about an hour ago +View.Time.XHoursAgo=${hour} hours ago +View.Time.ADayAgo=about a day ago +View.Time.XDaysAgo=${day} days ago +View.Time.AWeekAgo=about a week ago +View.Time.XWeeksAgo=${week} week ago +View.Time.AMonthAgo=about a month ago +View.Time.XMonthsAgo=${month} months ago +View.Time.AYearAgo=about a year ago +View.Time.XYearsAgo=${year} years ago + WebInterface.DefaultText.StatusUpdate=What’s on your mind? WebInterface.DefaultText.Message=Write a Message… WebInterface.DefaultText.Reply=Write a Reply… diff --git a/src/main/resources/static/javascript/sone.js b/src/main/resources/static/javascript/sone.js index 232d9fc..86475e5 100644 --- a/src/main/resources/static/javascript/sone.js +++ b/src/main/resources/static/javascript/sone.js @@ -712,9 +712,12 @@ function ajaxifyPost(postElement) { addCommentLink(getPostId(postElement), postElement, $(postElement).find(".post-status-line .time")); /* 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) { @@ -1027,6 +1030,7 @@ function loadNewPost(postId, soneId, recipientId, time) { newPost.insertBefore(firstOlderPost); } ajaxifyPost(newPost); + updatePostTimes(data.post.id); newPost.slideDown(); setActivity(); } @@ -1065,6 +1069,7 @@ function loadNewReply(replyId, soneId, postId, postSoneId) { } } ajaxifyReply(newReply); + updateReplyTimes(data.reply.id); newReply.slideDown(); setActivity(); return false; @@ -1113,6 +1118,86 @@ function markReplyAsKnown(replyElements) { }); } +/** + * 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) { + if (!getReply(replyId).is(":visible")) { + return; + } + 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) { @@ -1371,6 +1456,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) { -- 2.7.4