Repository: freedomofpress/securedrop

Issue:
Display relative times to the user
It is probably nicer to present the user with relative time representations rather than a timestamp  ( i.e. uploaded 35 minutes ago). We don't want users wasting mental compute cycles on parsing a timestamp.

As SecureDrop users would typically be dealing with recent messages and interactions, I'd suggest only providing relative times for up to a month before fallback to a regular date time. The relative time could also have a tooltip displaying the full time.
- Just now
- 10 minutes ago
- 1 hour ago
- 22 days ago
- Sep 11, 2014 06:45 PM

Let me know your opinions and if there is anywhere relative timestamps should not be used



Discussion:

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -3,6 +3,7 @@
 import version
 import crypto_util
 import store
+import template_filters
 from db import db_session, Source, Submission, SourceStar, get_one_or_else
 
 import os
@@ -25,12 +26,7 @@
     app.jinja_env.globals['header_image'] = 'logo.png'
     app.jinja_env.globals['use_custom_header_image'] = False
 
-
-@app.template_filter('datetimeformat')
-def _jinja2_datetimeformat(dt, fmt=None):
-    """Template filter for readable formatting of datetime.datetime"""
-    fmt = fmt or '%b %d, %Y %I:%M %p'
-    return dt.strftime(fmt)
+app.jinja_env.filters['datetimeformat'] = template_filters.datetimeformat
 
 
 @app.teardown_appcontext
diff --git a/securedrop/source.py b/securedrop/source.py
--- a/securedrop/source.py
+++ b/securedrop/source.py
@@ -23,6 +23,7 @@
 import crypto_util
 import store
 import background
+import template_filters
 from db import db_session, Source, Submission
 from request_that_secures_file_uploads import RequestThatSecuresFileUploads
 
@@ -43,12 +44,7 @@
     app.jinja_env.globals['header_image'] = 'logo.png'
     app.jinja_env.globals['use_custom_header_image'] = False
 
-@app.template_filter('datetimeformat')
-def _jinja2_datetimeformat(dt, fmt=None):
-    """Template filter for readable formatting of datetime.datetime"""
-    fmt = fmt or '%b %d, %Y %I:%M %p'
-    return dt.strftime(fmt)
-
+app.jinja_env.filters['datetimeformat'] = template_filters.datetimeformat
 
 @app.teardown_appcontext
 def shutdown_session(exception=None):
@@ -177,7 +173,7 @@ def lookup():
             except UnicodeDecodeError:
                 app.logger.error("Could not decode reply %s" % fn)
             else:
-                date = datetime.fromtimestamp(os.stat(store.path(g.sid, fn)).st_mtime).strftime("%b %d, %Y %I:%M %p")
+                date = datetime.fromtimestamp(os.stat(store.path(g.sid, fn)).st_mtime)
                 replies.append(dict(id=fn, date=date, msg=msg))
 
     def async_genkey(sid, codename):
diff --git a/securedrop/template_filters.py b/securedrop/template_filters.py
new file mode 100644
--- /dev/null
+++ b/securedrop/template_filters.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime
+
+
+def datetimeformat(dt, fmt=None, relative=False):
+    """Template filter for readable formatting of datetime.datetime"""
+    fmt = fmt or '%b %d, %Y %I:%M %p'
+    if relative:
+        time_difference = _relative_timestamp(dt)
+        if time_difference:
+            return '{} ago'.format(time_difference)
+    return dt.strftime(fmt)
+
+
+def _relative_timestamp(dt):
+    """"
+    Format a human readable relative time for timestamps up to 30 days old
+    """
+    delta = datetime.now() - dt
+    diff = (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
+    if diff < 45:
+        return 'seconds'
+    elif diff < 90:
+        return 'a minute'
+    elif diff < 2700:
+        return '{} minutes'.format(int(max(diff / 60, 2)))
+    elif diff < 5400:
+        return 'an hour'
+    elif diff < 79200:
+        return '{} hours'.format(int(max(diff / 3600, 2)))
+    elif diff < 129600:
+        return 'a day'
+    elif diff < 2592000:
+        return '{} days'.format(int(max(diff / 86400, 2)))
+    else:
+        return None


Test patch: