From 2bf54edb9c03a812e91ca06f663357dae2848388 Mon Sep 17 00:00:00 2001 From: rhymes Date: Tue, 18 Jun 2019 20:58:24 +0200 Subject: [PATCH] Pro: History (#3220) * Add base history support * Add tags and ranking and make it work * Fix styling * Add article title and text excerpt to the index * Add pagination support * Debounce the search to 300ms * Show history only to pro users * Use routing helpers instead of hardcoding * Raise the page size to 100 * Remove console log * A bit of regrouping --- .../initializers/initializeEllipsisMenu.js | 14 +- .../utilities/checkUserLoggedIn.js | 9 +- app/assets/stylesheets/history.scss | 237 ++++++++++++++++++ app/assets/stylesheets/scaffolds.scss | 28 ++- app/controllers/history_controller.rb | 16 ++ .../reading_list_items_controller.rb | 2 + app/javascript/history/history.jsx | 227 +++++++++++++++++ app/javascript/packs/history.jsx | 22 ++ app/javascript/readingList/readingList.jsx | 19 +- app/javascript/src/utils/algolia.js | 7 + app/models/page_view.rb | 57 +++++ app/views/articles/_sidebar_nav.html.erb | 12 +- app/views/history/index.html.erb | 26 ++ app/views/layouts/_footer.html.erb | 4 +- app/views/layouts/_styles.html.erb | 5 +- config/routes.rb | 5 +- package.json | 5 +- spec/requests/history_spec.rb | 20 ++ yarn.lock | 2 +- 19 files changed, 682 insertions(+), 35 deletions(-) create mode 100644 app/assets/stylesheets/history.scss create mode 100644 app/controllers/history_controller.rb create mode 100644 app/javascript/history/history.jsx create mode 100644 app/javascript/packs/history.jsx create mode 100644 app/javascript/src/utils/algolia.js create mode 100644 app/views/history/index.html.erb create mode 100644 spec/requests/history_spec.rb diff --git a/app/assets/javascripts/initializers/initializeEllipsisMenu.js b/app/assets/javascripts/initializers/initializeEllipsisMenu.js index 0f60a12d0..5e1e0f5fe 100644 --- a/app/assets/javascripts/initializers/initializeEllipsisMenu.js +++ b/app/assets/javascripts/initializers/initializeEllipsisMenu.js @@ -63,12 +63,15 @@ function handleFormSubmit(e) { xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(data); - xhr.onload = function() { + xhr.onload = function onload() { var article = form.closest('div.single-article'); if (xhr.status === 200) { onXhrSuccess(form, article, values); - var message = values.commit === 'Mute Notifications' ? 'Notifications Muted' : 'Notifications Restored'; + var message = + values.commit === 'Mute Notifications' + ? 'Notifications Muted' + : 'Notifications Restored'; article.querySelector('.dashboard-meta-details').innerHTML = message; } else { article.querySelector('.dashboard-meta-details').innerHTML = @@ -141,9 +144,10 @@ function initializeEllipsisMenuToggle() { } // Hide ellipsis menus when you click outside of the ellipsis menu parent div - document - .getElementsByTagName('BODY')[0] - .addEventListener('click', hideEllipsisMenus); + const body = document.getElementsByTagName('body')[0]; + if (body) { + body.addEventListener('click', hideEllipsisMenus); + } } function initializeEllipsisMenu() { diff --git a/app/assets/javascripts/utilities/checkUserLoggedIn.js b/app/assets/javascripts/utilities/checkUserLoggedIn.js index 93aa3cea7..1bd909af6 100644 --- a/app/assets/javascripts/utilities/checkUserLoggedIn.js +++ b/app/assets/javascripts/utilities/checkUserLoggedIn.js @@ -1,3 +1,8 @@ function checkUserLoggedIn() { - return document.getElementsByTagName('body')[0].getAttribute('data-user-status') == "logged-in" -} \ No newline at end of file + const body = document.getElementsByTagName('body')[0]; + if (!body) { + return false; + } + + return body.getAttribute('data-user-status') === 'logged-in'; +} diff --git a/app/assets/stylesheets/history.scss b/app/assets/stylesheets/history.scss new file mode 100644 index 000000000..32328c796 --- /dev/null +++ b/app/assets/stylesheets/history.scss @@ -0,0 +1,237 @@ +@import 'variables'; +@import 'mixins'; + +.history-container { + @media screen and (min-width: 950px) { + margin-top: -25px; + } + + a { + display: block; + } + + .history-filters { + width: 300px; + float: left; + min-height: 356px; + position: relative; + + input { + width: 100%; + border: 0px; + padding: 5px; + @include themeable( + border, + theme-container-border, + 1px solid $light-medium-gray + ); + border-radius: 3px; + font-size: 1em; + box-sizing: border-box; + margin-bottom: 4px; + @include themeable( + background, + theme-container-accent-background, + lighten($light-gray, 2%) + ); + @include themeable(color, theme-color, $black); + } + + .history-tags { + padding-bottom: 25px; + } + + .history-tag { + padding: 5px 0px; + position: relative; + padding-left: 5px; + @include themeable(color, theme-color, $black); + &:hover { + @include themeable( + background, + theme-container-background-hover, + $light-gray + ); + } + &.selected { + background: $purple; + } + } + + .history-view-toggle { + position: absolute; + bottom: 10px; + left: 15px; + font-size: 0.8em; + } + } + + .history-results { + width: calc(100% - 20px); + margin: 10px auto; + margin-left: 10px; + float: left; + @include themeable(background, theme-container-background, white); + @include themeable( + border, + theme-container-border, + 1px solid $light-medium-gray + ); + border-radius: 3px; + min-height: 350px; + display: none; + text-align: left; + font-size: 14px; + + @media screen and (min-width: 950px) { + width: calc(100% - 332px); + margin-left: 20px; + margin-top: 0px; + font-size: 17px; + } + + &.history-results--loaded { + display: block; + } + + .history-results-header { + @include themeable( + background, + theme-container-accent-background, + darken($light-gray, 2%) + ); + padding: 17px; + font-size: 1.8em; + font-weight: bold; + } + + .history-item-wrapper { + position: relative; + .history-archive-butt { + position: absolute; + bottom: 6px; + right: 5px; + font-size: 11px; + font-weight: bold; + border-radius: 3px; + background: transparent; + border: 1px solid $black; + @include themeable(color, theme-secondary-color, $medium-gray); + @include themeable(border-color, theme-secondary-color, $medium-gray); + } + } + + .history-item { + @include themeable(color, theme-color, $black); + font-size: 0.8em; + padding: 17px 17px 25px; + @include themeable( + border-bottom, + theme-container-border, + 1px solid $light-medium-gray + ); + + .history-item-title { + font-weight: bold; + font-size: 1.7em; + margin-bottom: 10px; + } + + .history-item-details { + a { + display: inline-block; + @include themeable(color, theme-secondary-color, $medium-gray); + &.history-item-user { + margin-right: 5px; + img { + height: 22px; + width: 22px; + border-radius: 100px; + vertical-align: -5px; + margin-right: 5px; + } + } + .history-item-tag-collection { + display: inline-block; + } + &.history-item-tag { + margin-left: 1px; + padding: 1px 2px; + border-radius: 3px; + } + } + } + + &:hover { + @include themeable( + background, + theme-container-background-hover, + $light-gray + ); + } + } + + .history-empty { + text-align: center; + padding: 100px 0px; + span.highlight { + background: $bold-blue; + border-radius: 3px; + padding: 3px 8px; + margin: 0px 8px; + color: white; + display: inline-block; + span[role='img'] { + margin-left: 8px; + } + } + } + + .history-results-load-more { + text-align: center; + + button { + background: transparent; + @include themeable(border, theme-border, 1px solid $light-medium-gray); + font-size: 17px; + padding: 14px 5px; + margin: 30px auto; + width: 120px; + max-width: 80%; + border-radius: 100px; + font-weight: bold; + } + } + } +} + +@media screen and (max-width: 949px) { + .history-home { + .side-bar { + display: block; + position: relative; + width: calc(100% - 15px); + margin: 10px auto; + margin-left: 10px; + margin-bottom: -15px; + .history-filters { + height: 85px; + min-height: 85px; + .history-tags { + height: 40px; + overflow: hidden; + width: 100%; + overflow-x: scroll; + overflow-y: hidden; + white-space: nowrap; + .history-tag { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + margin-right: 3px; + } + } + } + } + } +} diff --git a/app/assets/stylesheets/scaffolds.scss b/app/assets/stylesheets/scaffolds.scss index c8084dfe7..1bfa0c452 100644 --- a/app/assets/stylesheets/scaffolds.scss +++ b/app/assets/stylesheets/scaffolds.scss @@ -35,7 +35,9 @@ body { } .zen-mode { - .top-bar, .primary-sticky-nav, .article-actions { + .top-bar, + .primary-sticky-nav, + .article-actions { display: none !important; } } @@ -57,6 +59,7 @@ body { &.stories-search, &.podcast_episodes-index, &.reading_list_items-index, + &.history-index, .tags-index, &.twitch_live_streams-show { margin-top: 68px; @@ -136,7 +139,11 @@ input[type='email'] { bottom: 20px; left: 20px; right: 20px; - @include themeable(background, theme-container-accent-background, $light-purple); + @include themeable( + background, + theme-container-accent-background, + $light-purple + ); @include themeable(border, container-border, 2px solid $purple); padding: 12px 30px; z-index: 20; @@ -149,12 +156,19 @@ input[type='email'] { -webkit-animation-name: slide-in-from-left; -webkit-animation-duration: 0.25s; } - } @-webkit-keyframes slide-in-from-left { - 0% { left: -300px;} - 50%{ left : 0px;} - 90%{ left : 24px;} - 100%{ left: 20px;} + 0% { + left: -300px; + } + 50% { + left: 0px; + } + 90% { + left: 24px; + } + 100% { + left: 20px; + } } diff --git a/app/controllers/history_controller.rb b/app/controllers/history_controller.rb new file mode 100644 index 000000000..b264c3c0d --- /dev/null +++ b/app/controllers/history_controller.rb @@ -0,0 +1,16 @@ +class HistoryController < ApplicationController + before_action :generate_algolia_search_key + + def index + authorize current_user, :pro_user? + @history_index = true # used exclusively by the ERb templates + end + + private + + def generate_algolia_search_key + params = { filters: "viewable_by:#{current_user.id}" } + key = ApplicationConfig["ALGOLIASEARCH_SEARCH_ONLY_KEY"] + @secured_algolia_key = Algolia.generate_secured_api_key(key, params) + end +end diff --git a/app/controllers/reading_list_items_controller.rb b/app/controllers/reading_list_items_controller.rb index fbdd5916a..68053d5a4 100644 --- a/app/controllers/reading_list_items_controller.rb +++ b/app/controllers/reading_list_items_controller.rb @@ -14,6 +14,8 @@ class ReadingListItemsController < ApplicationController head :ok end + private + def generate_algolia_search_key params = { filters: "viewable_by:#{current_user.id}" } @secured_algolia_key = Algolia.generate_secured_api_key( diff --git a/app/javascript/history/history.jsx b/app/javascript/history/history.jsx new file mode 100644 index 000000000..ae76a84b1 --- /dev/null +++ b/app/javascript/history/history.jsx @@ -0,0 +1,227 @@ +import { h, Component } from 'preact'; +import { PropTypes } from 'preact-compat'; +import debounce from 'lodash.debounce'; +import setupAlgoliaIndex from '../src/utils/algolia'; + +export class History extends Component { + constructor(props) { + super(props); + + const { availableTags } = this.props; + this.state = { + query: '', + items: [], + totalCount: 0, + index: null, + itemsLoaded: false, + hitsPerPage: 100, + availableTags, + selectedTags: [], + page: 0, + showNextPageButton: false, + }; + + this.handleTyping = debounce(this.handleTyping.bind(this), 300, { + leading: true, + }); + } + + componentDidMount() { + const index = setupAlgoliaIndex({ + containerId: 'history', + indexName: 'UserHistory', + }); + + // get default result set from Algolia + const { hitsPerPage } = this.state; + index.search('', { hitsPerPage }).then(content => { + this.setState({ + items: content.hits, + totalCount: content.nbHits, + index, + itemsLoaded: true, + showNextPageButton: content.hits.length === hitsPerPage, + }); + }); + } + + handleTyping = event => { + const query = event.target.value; + const { selectedTags } = this.state; + + this.setState({ page: 0, items: [] }); + this.search(query, { tags: selectedTags }); + }; + + toggleTag = (event, tag) => { + event.preventDefault(); + + const { query, selectedTags } = this.state; + const newTags = selectedTags; + if (newTags.indexOf(tag) === -1) { + newTags.push(tag); + } else { + newTags.splice(newTags.indexOf(tag), 1); + } + + this.setState({ selectedTags: newTags, page: 0, items: [] }); + this.search(query, { tags: newTags }); + }; + + loadNextPage = () => { + const { query, selectedTags, page } = this.state; + this.setState({ page: page + 1 }); + this.search(query, { selectedTags }); + }; + + search(query, { tags }) { + const { index, hitsPerPage, page, items } = this.state; + const filters = { hitsPerPage, page }; + + if (tags && tags.length > 0) { + filters.tagFilters = tags; + } + + index.search(query, filters).then(content => { + // add new items to the bottom + const allItems = items; + const itemsIds = items.map(i => i.objectID); + content.hits.forEach(item => { + if (!itemsIds.includes(item.objectID)) { + allItems.push(item); + } + }); + + this.setState({ + query, + items: allItems, + totalCount: content.nbHits, + showNextPageButton: content.hits.length === hitsPerPage, + }); + }); + } + + renderNoItems() { + const { selectedTags, query } = this.state; + + return ( +
+

+ {selectedTags.length === 0 && query.length === 0 + ? 'Your History is Lonely' + : 'Nothing with this filter 🤔'} +

+
+ ); + } + + renderItems() { + const { items, itemsLoaded } = this.state; + + if (items.length === 0 && itemsLoaded) { + return this.renderNoItems(); + } + + return items.map(item => ( +
+ +
{item.article_title}
+ +
+ + Profile Pic + {item.article_user.name} +・ + {item.article_reading_time} + {' '} +min read・ + {`visited on ${item.readable_visited_at}`} +・ + + + {item.article_tags.map(tag => ( + + # + {tag} + + ))} + +
+ +
+ )); + } + + renderTags() { + const { availableTags, selectedTags } = this.state; + + return availableTags.map(tag => ( + -1 ? 'selected' : '' + }`} + href={`/t/${tag}`} + data-no-instant + onClick={e => this.toggleTag(e, tag)} + > + # + {tag} + + )); + } + + renderNextPageButton() { + const { showNextPageButton } = this.state; + + if (showNextPageButton) { + return ( +
+ +
+ ); + } + return ''; + + } + + render() { + const { itemsLoaded, totalCount } = this.state; + + const allItems = this.renderItems(); + const allTags = this.renderTags(); + const nextPageButton = this.renderNextPageButton(); + + return ( +
+
+
+ +
{allTags}
+
+
+
+
+ History + {' '} + {`(${totalCount})`} +
+
{allItems}
+ {nextPageButton} +
+
+ ); + } +} + +History.propTypes = { + availableTags: PropTypes.arrayOf(PropTypes.string).isRequired, +}; diff --git a/app/javascript/packs/history.jsx b/app/javascript/packs/history.jsx new file mode 100644 index 000000000..310600ee6 --- /dev/null +++ b/app/javascript/packs/history.jsx @@ -0,0 +1,22 @@ +import { h, render } from 'preact'; +import { getUserDataAndCsrfToken } from '../chat/util'; +import { History } from '../history/history'; + +function loadComponent() { + getUserDataAndCsrfToken().then(({ currentUser }) => { + const root = document.getElementById('history'); + if (root) { + render( + , + root, + root.firstElementChild, + ); + } + }); +} + +window.InstantClick.on('change', () => { + loadComponent(); +}); + +loadComponent(); diff --git a/app/javascript/readingList/readingList.jsx b/app/javascript/readingList/readingList.jsx index 05b502d5a..45ba279b6 100644 --- a/app/javascript/readingList/readingList.jsx +++ b/app/javascript/readingList/readingList.jsx @@ -128,13 +128,19 @@ export class ReadingList extends Component { src={item.reactable_user.profile_image_90} alt="Profile Pic" /> - {item.reactable_user.name}・{item.reactable_published_date}・ - {item.reading_time} min read・ + {item.reactable_user.name} +・ + {item.reactable_published_date} +・ + {item.reading_time} + {' '} +min read・ {item.reactable_tags.map(tag => ( - #{tag} + # + {tag} ))} @@ -160,9 +166,9 @@ export class ReadingList extends Component {

Hit the - SAVE + SAVE or - + Bookmark 🔖 @@ -193,7 +199,8 @@ export class ReadingList extends Component { data-no-instant onClick={e => this.toggleTag(e, tag)} > - #{tag} + # + {tag} )); const snackBar = archiving ? ( diff --git a/app/javascript/src/utils/algolia.js b/app/javascript/src/utils/algolia.js new file mode 100644 index 000000000..66248ce20 --- /dev/null +++ b/app/javascript/src/utils/algolia.js @@ -0,0 +1,7 @@ +export default function setupAlgoliaIndex({ containerId, indexName }) { + const id = document.querySelector("meta[name='algolia-public-id']").content; + const key = document.getElementById(containerId).dataset.algoliaKey; + const env = document.querySelector("meta[name='environment']").content; + const client = algoliasearch(id, key); + return client.initIndex(`${indexName}_${env}`); +} diff --git a/app/models/page_view.rb b/app/models/page_view.rb index 96b6621c5..deb027b80 100644 --- a/app/models/page_view.rb +++ b/app/models/page_view.rb @@ -1,4 +1,61 @@ class PageView < ApplicationRecord + include AlgoliaSearch + belongs_to :user, optional: true belongs_to :article + + algoliasearch index_name: "UserHistory", per_environment: true, if: :belongs_to_pro_user? do + attributes :referrer, :time_tracked_in_seconds, :user_agent, :article_tags + + attribute(:article_title) { article.title } + attribute(:article_path) { article.path } + attribute(:article_reading_time) { article.reading_time } + attribute(:viewable_by) { user_id } + attribute(:visited_at_timestamp) { created_at.to_i } + + attribute :article_user do + user = article.user + { + username: user.username, + name: user.name, + profile_image_90: user.profile_image_90 + } + end + + attribute :readable_visited_at do + if created_at.year == Time.current.year + created_at.strftime("%b %e") + else + created_at.strftime("%b %e '%y") + end + end + + searchableAttributes( + %i[referrer user_agent article_title article_searchable_tags article_searchable_text], + ) + + tags { article_tags } + + attributesForFaceting ["filterOnly(viewable_by)"] + + customRanking ["desc(visited_at_timestamp)"] + end + + private + + def belongs_to_pro_user? + user&.pro? + end + + def article_searchable_tags + article.cached_tag_list + end + + def article_searchable_text + article.body_text[0..350] + end + + def article_tags + article.decorate.cached_tag_list_array + end end diff --git a/app/views/articles/_sidebar_nav.html.erb b/app/views/articles/_sidebar_nav.html.erb index 1ff26c89a..d60bf3c1b 100644 --- a/app/views/articles/_sidebar_nav.html.erb +++ b/app/views/articles/_sidebar_nav.html.erb @@ -19,17 +19,17 @@ navigation @@ -49,7 +49,7 @@ <% end %> <% Tag.where(supported: true).order("hotness_score DESC").limit(30).pluck(:id, :name).each do |tag_array| %> diff --git a/app/views/history/index.html.erb b/app/views/history/index.html.erb new file mode 100644 index 000000000..237d4559c --- /dev/null +++ b/app/views/history/index.html.erb @@ -0,0 +1,26 @@ +<% title "History" %> + +<%= content_for :page_meta do %> + <% page_url = "#{ApplicationConfig["APP_PROTOCOL"]}#{ApplicationConfig["APP_DOMAIN"]}#{history_path}" %> + + + + + + + + + + + + "> + + +<% end %> + +
+
+
+
+ +<%= javascript_pack_tag "history", defer: true %> diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index c2a903d41..3105d3aef 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -2,9 +2,9 @@ -<%= javascript_include_tag "hello-dev.js", defer: true %> +<%# javascript_include_tag "hello-dev.js", defer: true %> diff --git a/app/views/layouts/_styles.html.erb b/app/views/layouts/_styles.html.erb index fa54fa25d..bfa8d7892 100644 --- a/app/views/layouts/_styles.html.erb +++ b/app/views/layouts/_styles.html.erb @@ -1,4 +1,4 @@ -<% cache "base_inline_styles_#{@story_show}_#{@article_index}_#{@home_page}_#{@article_show}_#{view_class}_#{@notifications_index}_#{core_pages?}_#{@tags_index}_#{@reading_list_items_index}_#{ApplicationConfig['DEPLOYMENT_SIGNATURE']}_#{user_signed_in?}", expires_in: 8.hours do %> +<% cache "base_inline_styles_#{@story_show}_#{@article_index}_#{@home_page}_#{@article_show}_#{view_class}_#{@notifications_index}_#{core_pages?}_#{@tags_index}_#{@reading_list_items_index}_#{@history_index}_#{ApplicationConfig['DEPLOYMENT_SIGNATURE']}_#{user_signed_in?}", expires_in: 8.hours do %> <% if @story_show %> - <% elsif @notifications_index || @reading_list_items_index %> + <% elsif @notifications_index || @reading_list_items_index || @history_index %>