Show localized date/time on hover for articles/comments (#2722)

* Add article decorator published_timestamp

* Use time HTML5 element and refactor date in partial

* Add published_timestamp to Article

Adding `published_timestamp` to the homepage we can then use JS to render the full timestamp localized for the user.

We've also added the timestamp to the index and the API

* Display article published timestamp on hover

* Use time also in the article show page

* Add timestamp to bottom articles as well

* Remove published_timestamp from index because it is not used

* Fix broken specs

* Add more article dates specs

* Refactor date initializers
This commit is contained in:
rhymes 2019-05-09 17:05:17 +02:00 committed by Ben Halpern
parent 260fe901b8
commit 4e591fea1c
25 changed files with 211 additions and 101 deletions

View file

@ -28,6 +28,7 @@ function callInitalizers(){
initializeCommentsPage();
initEditorResize();
initLeaveEditorWarning();
initializeArticleDate();
initializeArticleReactions();
initNotifications();
initializeSplitTestTracking();

View file

@ -0,0 +1,10 @@
/* Show article date/time according to user's locale */
/* global addLocalizedDateTimeToElementsTitles */
function initializeArticleDate() {
var articlesDates = document.querySelectorAll(
'.single-article time, article time, .single-other-article time',
);
addLocalizedDateTimeToElementsTitles(articlesDates, 'datetime');
}

View file

@ -1,50 +1,8 @@
/* Show comment date/time according to user's locale */
/* global timestampToLocalDateTime */
/* global addLocalizedDateTimeToElementsTitles */
function initializeCommentDate() {
// example: "Wednesday, April 3, 2019, 2:55:14 PM"
var hoverTimeOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
};
var commentsDates = document.querySelectorAll('.comment-date time');
// example: "Apr 3"
var visibleDateOptions = {
month: 'short',
day: 'numeric',
};
var commentDates = document.getElementsByClassName('comment-date');
for (var i = 0; i < commentDates.length; i += 1) {
// get UTC timestamp set by the server
var ts = commentDates[i].getAttribute('data-published-timestamp');
// add a full datetime to the comment date string, visible on hover
// `navigator.language` is used for full date times to allow the hover date
// to be localized according to the user's locale
var hoverTime = timestampToLocalDateTime(
ts,
navigator.language,
hoverTimeOptions,
);
commentDates[i].setAttribute('title', hoverTime);
// replace the comment short visible date with the equivalent localized one
var visibleDate = commentDates[i].querySelector('a');
if (visibleDate) {
var localVisibleDate = timestampToLocalDateTime(
ts,
'en-US', // en-US because for now we want all users to see `Apr 3`
visibleDateOptions,
);
if (localVisibleDate) {
visibleDate.innerHTML = localVisibleDate;
}
}
}
addLocalizedDateTimeToElementsTitles(commentsDates, 'datetime');
}

View file

@ -90,7 +90,11 @@ function buildArticleHTML(article) {
var publishDate = '';
if (article.readable_publish_date) {
publishDate = '・'+article.readable_publish_date
if (article.published_timestamp) {
publishDate = '・' + '<time datetime="'+article.published_timestamp+'">'+article.readable_publish_date+'</time>';
} else {
publishDate = '・' + '<time>'+article.readable_publish_date+'</time>';
}
}
var readingTimeHTML = '';
if (article.reading_time && article.class_name === "Article") {

View file

@ -43,8 +43,12 @@ function buildCommentHTML(comment) {
</a>\
'+twitterIcon+'\
'+githubIcon+'\
<div class="comment-date" data-published-timestamp="' + comment.published_timestamp + '">\
<a href="'+comment.url+'">'+comment.readable_publish_date+'</a>\
<div class="comment-date">\
<a href="'+comment.url+'">\
<time datetime="'+comment.published_timestamp+'">\
'+comment.readable_publish_date+'\
</time>\
</a>\
</div>\
<button class="dropbtn">\
<%= image_tag("three-dots.svg", class: "dropdown-icon", alt: "Toggle dropdown menu") %>\

View file

@ -1,3 +1,5 @@
/* Local date/time utilities */
/*
Convert string timestamp to local time, using the given locale.
@ -20,3 +22,36 @@ function timestampToLocalDateTime(timestamp, locale, options) {
return '';
}
}
function addLocalizedDateTimeToElementsTitles(elements, timestampAttribute) {
// example: "Wednesday, April 3, 2019, 2:55:14 PM"
var timeOptions = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
};
for (var i = 0; i < elements.length; i += 1) {
var element = elements[i];
// get UTC timestamp set by the server
var timestamp = element.getAttribute(timestampAttribute || 'datetime');
if (timestamp) {
// add a full datetime to the element title, visible on hover.
// `navigator.language` is used to allow the date to be localized
// according to the browser's locale
// see <https://developer.mozilla.org/en-US/docs/Web/API/NavigatorLanguage/language>
var localDateTime = timestampToLocalDateTime(
timestamp,
navigator.language,
timeOptions,
);
element.setAttribute('title', localDateTime);
}
}
}

View file

@ -690,7 +690,7 @@ a.header-link {
.comment-date {
border: none;
position: absolute;
top: calc(16px - 0.25vw);
top: calc(14px - 0.25vw);
right: calc(35px + 0.2vw);
font-size: 12px;
text-align: right;

View file

View file

@ -81,7 +81,7 @@ class Article < ApplicationRecord
}
scope :limited_column_select, lambda {
select(:path, :title, :id,
select(:path, :title, :id, :published,
:comments_count, :positive_reactions_count, :cached_tag_list,
:main_image, :main_image_background_hex_color, :updated_at, :slug,
:video, :user_id, :organization_id, :video_source_url, :video_code,
@ -131,10 +131,7 @@ class Article < ApplicationRecord
algoliasearch per_environment: true, auto_remove: false, enqueue: :trigger_delayed_index do
attribute :title
add_index "searchables",
id: :index_id,
per_environment: true,
enqueue: :trigger_delayed_index do
add_index "searchables", id: :index_id, per_environment: true, enqueue: :trigger_delayed_index do
attributes :title, :tag_list, :main_image, :id, :reading_time, :score,
:featured, :published, :published_at, :featured_number,
:comments_count, :reactions_count, :positive_reactions_count,
@ -163,10 +160,7 @@ class Article < ApplicationRecord
customRanking ["desc(search_score)", "desc(hotness_score)"]
end
add_index "ordered_articles",
id: :index_id,
per_environment: true,
enqueue: :trigger_delayed_index do
add_index "ordered_articles", id: :index_id, per_environment: true, enqueue: :trigger_delayed_index do
attributes :title, :path, :class_name, :comments_count, :reading_time, :language,
:tag_list, :positive_reactions_count, :id, :hotness_score, :score, :readable_publish_date, :flare_tag, :user_id,
:organization_id, :cloudinary_video_url, :video_duration_in_minutes, :experience_level_rating, :experience_level_rating_distribution
@ -385,6 +379,13 @@ class Article < ApplicationRecord
end
end
def published_timestamp
return "" unless published
return "" unless crossposted_at || published_at
(crossposted_at || published_at).utc.iso8601
end
def self.seo_boostable(tag = nil, time_ago = 18.days.ago)
time_ago = 5.days.ago if time_ago == "latest" # Time ago sometimes returns this phrase instead of a date
time_ago = 75.days.ago if time_ago.nil? # Time ago sometimes is given as nil and should then be the default. I know, sloppy.
@ -539,7 +540,6 @@ class Article < ApplicationRecord
end
def update_cached_user
cached_org_object = nil
if organization
cached_org_object = {
name: organization.name,
@ -550,7 +550,7 @@ class Article < ApplicationRecord
}
self.cached_organization = OpenStruct.new(cached_org_object)
end
cached_user_object = nil
if user
cached_user_object = {
name: user.name,

View file

@ -3,7 +3,7 @@ json.array! @articles do |article|
json.id article.id
json.title article.title
json.description article.description
json.cover_image cloud_cover_url article.main_image
json.cover_image cloud_cover_url(article.main_image)
json.published_at article.published_at
json.tag_list article.cached_tag_list_array
json.slug article.slug
@ -12,6 +12,7 @@ json.array! @articles do |article|
json.canonical_url article.processed_canonical_url
json.comments_count article.comments_count
json.positive_reactions_count article.positive_reactions_count
json.published_timestamp article.published_timestamp
json.user do
json.name article.user.name

View file

@ -13,6 +13,7 @@ json.url @article.url
json.canonical_url @article.processed_canonical_url
json.comments_count @article.comments_count
json.positive_reactions_count @article.positive_reactions_count
json.published_timestamp @article.published_timestamp
json.body_html @article.processed_html
json.ltag_style(@article.liquid_tags_used.map { |ltag| Rails.application.assets["ltags/#{ltag}.css"].to_s.html_safe })

View file

@ -17,8 +17,7 @@
<div class="content">
<h3><%= article.title %></h3>
<h4>
<%= article.user.name %>
<span class="published-at">- <%= article.readable_publish_date if article.published_at %></span>
<%= article.user.name %> - <time datetime="<%= article.published_timestamp %>"><%= article.readable_publish_date %></time>
</h4>
</div>
</div>

View file

@ -27,8 +27,10 @@
</div>
</a>
<h4>
<a href="/<%= story.cached_user.username %>"><%= story.cached_user.name %>・<%= story.readable_publish_date %><span class="time-ago-indicator-initial-placeholder" data-seconds="<%= story.published_at_int %>"></span></a>
<a href="/<%= story.cached_user.username %>">
<%= story.cached_user.name %>・<time datetime="<%= story.published_timestamp %>"><%= story.readable_publish_date %></time>
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= story.published_at_int %>"></span>
</a>
</h4>
<div class="tags">
<% story.cached_tag_list_array.each do |tag| %>

View file

@ -31,7 +31,9 @@
data-algolia-tag=""
data-feed="<%= params[:timeframe] || "base-feed" %>"
data-articles-since="<%= Timeframer.new(params[:timeframe]).datetime.to_i %>">
<%= render "articles/sidebar" %>
<div class="articles-list" id="articles-list">
<div class="on-page-nav-controls" id="on-page-nav-controls">
<div class="on-page-nav-label">
@ -80,10 +82,12 @@
<img src="<%= asset_path "lightning.svg" %>" alt="right-sidebar-nav">
</button>
</div>
<% if @home_page %>
<% @featured_story ||= @stories.where.not(main_image: nil).first&.decorate || Article.new %>
<% @stories = @stories.decorate %>
<% end %>
<% end %>
<% if @featured_story.id %>
<div id="featured-story-marker" data-featured-article="articles-<%= @featured_story.id %>"></div>
<img src="<%= cloud_cover_url(@featured_story.main_image) %>" style="display:none" alt="<%= @featured_story.title %>" />
@ -98,8 +102,12 @@
<a href="/<%= @featured_story.cached_user.username %>" class="featured-profile-button">
<img class="featured-profile-pic" src="<%= ProfileImage.new(@featured_story.cached_user).get(90) %>" alt="<%= @featured_story.title %>" />
</a>
<div class="featured-user-name"><a href="/<%= @featured_story.cached_user.username %>"><%= @featured_story.cached_user.name %>
・<%= @featured_story.readable_publish_date %><span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span></a></div>
<div class="featured-user-name">
<a href="/<%= @featured_story.cached_user.username %>">
<%= @featured_story.cached_user.name %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span>
</a>
</div>
<div class="featured-tags tags">
<% @featured_story.cached_tag_list_array.each do |tag| %>
<a href="/t/<%= tag %>"><span class="tag">#<%= tag %></span></a>
@ -136,16 +144,20 @@
</div>
</a>
<% end %>
<div id="article-index-podcast-div"></div>
<div class="substories" id="substories">
<% if @stories.any? %>
<%= render "stories/main_stories_feed" %>
<% end %>
</div>
<div class="loading-articles" id="loading-articles">
loading...
</div>
</div>
<%= render "articles/sidebar_additional" %>
</div>

View file

@ -127,7 +127,9 @@
<% if @user.github_username.present? %>
<a href="http://github.com/<%= @user.github_username %>"><%= image_tag_or_inline_svg "github" %></a>
<% end %>
<span class="published-at" itemprop="datePublished"><%= @article.readable_publish_date if @article.published_at %></span>
<% if @article.published_timestamp.present? %>
<time itemprop="datePublished" datetime="<%= @article.published_timestamp %>"><%= @article.readable_publish_date %></time>
<% end %>
<% if @second_user.present? %>
<em>with <b><a href="<%= @second_user.path %>"><%= @second_user.name %></a></b></em>
<% end %>
@ -135,14 +137,16 @@
<em> and <b><a href="<%= @third_user.path %>"><%= @third_user.name %></a></b></em>
<% end %>
<% if should_show_updated_on?(@article) %>
<span class="published-at updated-at"><em>Updated on <span itemprop="dateModified"><%= @article.edited_at&.strftime("%b %d, %Y") %></span> </em></span>
<span><em>Updated on <time itemprop="dateModified" datetime="<%= @article.edited_at&.utc&.iso8601 %>"><%= @article.edited_at&.strftime("%b %d, %Y") %></time></em></span>
<% elsif should_show_crossposted_on?(@article) %>
<span class="published-at updated-at">
<span>
<em>
Originally published at
<a href="<%= @article.canonical_url || @article.feed_source_url %>" style="color:#1395b8"><%= get_host_without_www(@article.canonical_url || @article.feed_source_url) %></a>
on
<span class="posted-date-inline"><%= (@article.originally_published_at || @article.published_at).strftime("%b %d, %Y") if @article.crossposted_at %></span>
<% if @article.crossposted_at %>
<time datetime="<%= (@article.originally_published_at || @article.published_at)&.utc&.iso8601 %>"><%= (@article.originally_published_at || @article.published_at)&.strftime("%b %d, %Y") %></time>
<% end %>
</em>
</span>
<% end %>

View file

@ -0,0 +1,7 @@
<div class="comment-date">
<a href="<%= decorated_comment.path %>">
<time datetime="<%= decorated_comment.published_timestamp %>">
<%= decorated_comment.readable_publish_date %>
</time>
</a>
</div>

View file

@ -47,9 +47,7 @@
<% if commentable_author_is_op?(commentable, comment) %>
<span class="op-marker"><%= get_ama_or_op_banner(commentable) %></span>
<% end %>
<div class="comment-date" data-published-timestamp="<%= decorated_comment.published_timestamp %>">
<a href="<%= comment.path %>"><%= comment.readable_publish_date %></a>
</div>
<%= render "comments/comment_date", decorated_comment: decorated_comment %>
<button class="dropbtn" aria-label="Toggle dropdown menu">
<%= image_tag("three-dots.svg", class: "dropdown-icon", alt: "Dropdown menu icon") %>
</button>

View file

@ -1,24 +1,22 @@
<div class="liquid-comment">
<div class="details">
<a href="/<%= comment.user.username %>">
<img class="profile-pic" src="<%= ProfileImage.new(comment.user).get(50) %>" alt="<%= comment.user.username %> profile image"/>
<img class="profile-pic" src="<%= ProfileImage.new(comment.user).get(50) %>" alt="<%= comment.user.username %> profile image" />
</a>
<a href="/<%= comment.user.username %>">
<span class="comment-username"><%= comment.user.name %></span>
</a>
<% if comment.user.twitter_username.present? %>
<a href="https://twitter.com/<%= comment.user.twitter_username %>">
<img src="/assets/twitter-logo.svg" class="icon-img" alt="twitter"/>
<img src="/assets/twitter-logo.svg" class="icon-img" alt="twitter" />
</a>
<% end %>
<% if comment.user.github_username.present? %>
<a href="https://github.com/<%= comment.user.github_username %>">
<img src="/assets/github-logo.svg" class="icon-img" alt="github"/>
<img src="/assets/github-logo.svg" class="icon-img" alt="github" />
</a>
<% end %>
<div class="comment-date" data-published-timestamp="<%= comment.decorate.published_timestamp %>">
<a href="<%= comment.path %>"><%= comment.readable_publish_date %></a>
</div>
<%= render "comments/comment_date", decorated_comment: comment.decorate %>
</div>
<div class="body">
<%= comment.processed_html.html_safe %>

View file

@ -14,7 +14,8 @@
reading_time video_thumbnail_url video video_duration_in_minutes language
experience_level_rating experience_level_rating_distribution cached_user cached_organization],
methods: %i[readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes published_at_int],
cloudinary_video_url video_duration_in_minutes published_at_int
published_timestamp],
) %>">
</div>
<div id="home-articles-object" data-articles="
@ -23,7 +24,8 @@
reading_time video_thumbnail_url video video_duration_in_minutes language
experience_level_rating experience_level_rating_distribution cached_user cached_organization],
methods: %i[readable_publish_date cached_tag_list_array flare_tag class_name
cloudinary_video_url video_duration_in_minutes published_at_int],
cloudinary_video_url video_duration_in_minutes published_at_int
published_timestamp],
) %>">
<% 3.times do %>
<div class="single-article single-article-small-pic">

View file

@ -29,7 +29,9 @@
<a href="<%= comment.path %>">
<div class="single-comment <%= "strong-comment" if comment.ancestry.nil? %>">
<span class="comment-title">re: <%= comment.commentable.title %></span>
<span class="comment-date" data-published-timestamp="<%= comment.decorate.published_timestamp %>"><%= comment.readable_publish_date %></span>
<span class="comment-date">
<time datetime="<%= comment.decorate.published_timestamp %>"><%= comment.readable_publish_date %></time>
</span>
<div class="comment-preview"><%= truncate(strip_tags(comment.processed_html), length: 64).html_safe %></div>
</div>
</a>

View file

@ -507,5 +507,31 @@ RSpec.describe Article, type: :model do
end
end
describe "published_timestamp" do
it "returns empty string if the article is new" do
expect(Article.new.published_timestamp).to eq("")
end
it "returns empty string if the article is not published" do
article.update_column(:published, false)
expect(article.published_timestamp).to eq("")
end
it "returns the timestamp of the crossposting date over the publishing date" do
crossposted_at = 1.week.ago
published_at = 1.day.ago
article.update_columns(
published: true, crossposted_at: crossposted_at, published_at: published_at,
)
expect(article.published_timestamp).to eq(crossposted_at.utc.iso8601)
end
it "returns the timestamp of the publishing date if there is no crossposting date" do
published_at = 1.day.ago
article.update_columns(published: true, crossposted_at: nil, published_at: published_at)
expect(article.published_timestamp).to eq(published_at.utc.iso8601)
end
end
include_examples "#sync_reactions_count", :article
end

View file

@ -5,11 +5,9 @@ RSpec.describe "Views an article", type: :system do
let(:dir) { "../../support/fixtures/sample_article.txt" }
let(:template) { File.read(File.join(File.dirname(__FILE__), dir)) }
let!(:article) do
create(:article,
user_id: user.id,
body_markdown: template.gsub("false", "true"),
body_html: "")
create(:article, user_id: user.id, body_markdown: template.gsub("false", "true"), body_html: "")
end
let!(:timestamp) { "2019-03-04T10:00:00Z" }
before do
sign_in user
@ -25,4 +23,21 @@ RSpec.describe "Views an article", type: :system do
visit "/#{user.username}/#{article.slug}"
expect(page).to have_selector(".single-comment-node", visible: true, count: 3)
end
context "when showing the date" do
before do
article.update_column(:published_at, Time.zone.parse(timestamp))
end
it "shows the readable publish date" do
visit "/#{user.username}/#{article.slug}"
expect(page).to have_selector("article time", text: "Mar 4")
end
it "embeds the published timestamp" do
visit "/#{user.username}/#{article.slug}"
selector = "article time[datetime='#{timestamp}']"
expect(page).to have_selector(selector)
end
end
end

View file

@ -18,11 +18,11 @@ RSpec.describe "Viewing a comment", type: :system, js: true do
context "when showing the date" do
it "shows the readable publish date" do
expect(page).to have_selector(".comment-date", text: "Mar 4")
expect(page).to have_selector(".comment-date time", text: "Mar 4")
end
it "embeds the published timestamp" do
selector = ".comment-date[data-published-timestamp='#{timestamp}']"
selector = ".comment-date time[datetime='#{timestamp}']"
expect(page).to have_selector(selector)
end
end

View file

@ -5,21 +5,52 @@ RSpec.describe "User visits a homepage", type: :system do
let!(:article2) { create(:article, reactions_count: 20, featured: true) }
let!(:bad_article) { create(:article, reactions_count: 0) }
let!(:user) { create(:user) }
let!(:timestamp) { "2019-03-04T10:00:00Z" }
context "when no options specified" do
before { visit "/" }
context "when main featured article" do
before do
article.update_column(:published_at, Time.zone.parse(timestamp))
article2.update_column(:published_at, Time.zone.parse(timestamp))
visit "/"
end
it "shows the main article" do
expect(page).to have_selector(".big-article", visible: true)
it "shows the main article" do
expect(page).to have_selector(".big-article", visible: true)
end
it "shows the main article readable date" do
expect(page).to have_selector(".big-article time", text: "Mar 4")
end
it "embeds the main article published timestamp" do
selector = ".big-article time[datetime='#{timestamp}']"
expect(page).to have_selector(selector)
end
end
it "shows correct articles" do
article.update_column(:score, 15)
article2.update_column(:score, 15)
expect(page).to have_selector(".single-article", count: 2)
expect(page).to have_text(article.title)
expect(page).to have_text(article2.title)
expect(page).not_to have_text(bad_article.title)
context "when all other articles" do
before do
article.update_columns(score: 15, published_at: Time.zone.parse(timestamp))
article2.update_columns(score: 15, published_at: Time.zone.parse(timestamp))
visit "/"
end
it "shows correct articles" do
expect(page).to have_selector(".single-article", count: 2)
expect(page).to have_text(article.title)
expect(page).to have_text(article2.title)
expect(page).not_to have_text(bad_article.title)
end
it "shows all articles dates" do
expect(page).to have_selector(".single-article time", text: "Mar 4", count: 2)
end
it "embeds all articles published timestamps" do
selector = ".single-article time[datetime='#{timestamp}']"
expect(page).to have_selector(selector, count: 2)
end
end
end

View file

@ -52,7 +52,7 @@ RSpec.describe "User index", type: :system do
it "embeds comment timestamp" do
within("#substories .index-comments .single-comment") do
ts = comment.decorate.published_timestamp
timestamp_selector = ".comment-date[data-published-timestamp='#{ts}']"
timestamp_selector = ".comment-date time[datetime='#{ts}']"
expect(page).to have_selector(timestamp_selector)
end
end