Redesign public hashtag pages (#5237)
This commit is contained in:
		| @@ -1,17 +1,22 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class TagsController < ApplicationController | ||||
|   layout 'public' | ||||
|   before_action :set_body_classes | ||||
|   before_action :set_instance_presenter | ||||
|  | ||||
|   def show | ||||
|     @tag      = Tag.find_by!(name: params[:id].downcase) | ||||
|     @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) | ||||
|     @statuses = cache_collection(@statuses, Status) | ||||
|     @tag = Tag.find_by!(name: params[:id].downcase) | ||||
|  | ||||
|     respond_to do |format| | ||||
|       format.html | ||||
|       format.html do | ||||
|         serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer) | ||||
|         @initial_state_json   = serializable_resource.to_json | ||||
|       end | ||||
|  | ||||
|       format.json do | ||||
|         @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) | ||||
|         @statuses = cache_collection(@statuses, Status) | ||||
|  | ||||
|         render json: collection_presenter, | ||||
|                serializer: ActivityPub::CollectionSerializer, | ||||
|                adapter: ActivityPub::Adapter, | ||||
| @@ -22,6 +27,14 @@ class TagsController < ApplicationController | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def set_body_classes | ||||
|     @body_classes = 'tag-body' | ||||
|   end | ||||
|  | ||||
|   def set_instance_presenter | ||||
|     @instance_presenter = InstancePresenter.new | ||||
|   end | ||||
|  | ||||
|   def collection_presenter | ||||
|     ActivityPub::CollectionPresenter.new( | ||||
|       id: tag_url(@tag), | ||||
| @@ -30,4 +43,11 @@ class TagsController < ApplicationController | ||||
|       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def initial_state_params | ||||
|     { | ||||
|       settings: {}, | ||||
|       token: current_session&.token, | ||||
|     } | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store'; | ||||
| import { IntlProvider, addLocaleData } from 'react-intl'; | ||||
| import { getLocale } from '../locales'; | ||||
| import PublicTimeline from '../features/standalone/public_timeline'; | ||||
| import HashtagTimeline from '../features/standalone/hashtag_timeline'; | ||||
|  | ||||
| const { localeData, messages } = getLocale(); | ||||
| addLocaleData(localeData); | ||||
| @@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     locale: PropTypes.string.isRequired, | ||||
|     hashtag: PropTypes.string, | ||||
|   }; | ||||
|  | ||||
|   render () { | ||||
|     const { locale } = this.props; | ||||
|     const { locale, hashtag } = this.props; | ||||
|  | ||||
|     let timeline; | ||||
|  | ||||
|     if (hashtag) { | ||||
|       timeline = <HashtagTimeline hashtag={hashtag} />; | ||||
|     } else { | ||||
|       timeline = <PublicTimeline />; | ||||
|     } | ||||
|  | ||||
|     return ( | ||||
|       <IntlProvider locale={locale} messages={messages}> | ||||
|         <Provider store={store}> | ||||
|           <PublicTimeline /> | ||||
|           {timeline} | ||||
|         </Provider> | ||||
|       </IntlProvider> | ||||
|     ); | ||||
|   | ||||
| @@ -0,0 +1,70 @@ | ||||
| import React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import StatusListContainer from '../../ui/containers/status_list_container'; | ||||
| import { | ||||
|   refreshHashtagTimeline, | ||||
|   expandHashtagTimeline, | ||||
| } from '../../../actions/timelines'; | ||||
| import Column from '../../../components/column'; | ||||
| import ColumnHeader from '../../../components/column_header'; | ||||
|  | ||||
| @connect() | ||||
| export default class HashtagTimeline extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     dispatch: PropTypes.func.isRequired, | ||||
|     hashtag: PropTypes.string.isRequired, | ||||
|   }; | ||||
|  | ||||
|   handleHeaderClick = () => { | ||||
|     this.column.scrollTop(); | ||||
|   } | ||||
|  | ||||
|   setRef = c => { | ||||
|     this.column = c; | ||||
|   } | ||||
|  | ||||
|   componentDidMount () { | ||||
|     const { dispatch, hashtag } = this.props; | ||||
|  | ||||
|     dispatch(refreshHashtagTimeline(hashtag)); | ||||
|  | ||||
|     this.polling = setInterval(() => { | ||||
|       dispatch(refreshHashtagTimeline(hashtag)); | ||||
|     }, 10000); | ||||
|   } | ||||
|  | ||||
|   componentWillUnmount () { | ||||
|     if (typeof this.polling !== 'undefined') { | ||||
|       clearInterval(this.polling); | ||||
|       this.polling = null; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   handleLoadMore = () => { | ||||
|     this.props.dispatch(expandHashtagTimeline(this.props.hashtag)); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { hashtag } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <Column ref={this.setRef}> | ||||
|         <ColumnHeader | ||||
|           icon='hashtag' | ||||
|           title={hashtag} | ||||
|           onClick={this.handleHeaderClick} | ||||
|         /> | ||||
|  | ||||
|         <StatusListContainer | ||||
|           trackScroll={false} | ||||
|           scrollKey='standalone_hashtag_timeline' | ||||
|           timelineId={`hashtag:${hashtag}`} | ||||
|           loadMore={this.handleLoadMore} | ||||
|         /> | ||||
|       </Column> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -4,9 +4,9 @@ require.context('../images/', true); | ||||
|  | ||||
| function loaded() { | ||||
|   const TimelineContainer = require('../mastodon/containers/timeline_container').default; | ||||
|   const React = require('react'); | ||||
|   const ReactDOM = require('react-dom'); | ||||
|   const mountNode = document.getElementById('mastodon-timeline'); | ||||
|   const React             = require('react'); | ||||
|   const ReactDOM          = require('react-dom'); | ||||
|   const mountNode         = document.getElementById('mastodon-timeline'); | ||||
|  | ||||
|   if (mountNode !== null) { | ||||
|     const props = JSON.parse(mountNode.getAttribute('data-props')); | ||||
|   | ||||
| @@ -481,6 +481,7 @@ | ||||
|       flex: 0 0 auto; | ||||
|       background: $ui-base-color; | ||||
|       overflow: hidden; | ||||
|       border-radius: 4px; | ||||
|       box-shadow: 0 0 6px rgba($black, 0.1); | ||||
|  | ||||
|       .column-header { | ||||
| @@ -703,9 +704,99 @@ | ||||
|     .features #mastodon-timeline { | ||||
|       height: 70vh; | ||||
|       width: 100%; | ||||
|       min-width: 330px; | ||||
|       margin-bottom: 50px; | ||||
|  | ||||
|       .column { | ||||
|         width: 100%; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .cta { | ||||
|     margin: 20px; | ||||
|   } | ||||
|  | ||||
|   &.tag-page { | ||||
|     .brand { | ||||
|       padding-top: 20px; | ||||
|       margin-bottom: 20px; | ||||
|  | ||||
|       img { | ||||
|         height: 48px; | ||||
|         width: auto; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .container { | ||||
|       max-width: 690px; | ||||
|     } | ||||
|  | ||||
|     .cta { | ||||
|       margin: 40px 0; | ||||
|       margin-bottom: 80px; | ||||
|  | ||||
|       .button { | ||||
|         margin-right: 4px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .about-mastodon { | ||||
|       max-width: 330px; | ||||
|  | ||||
|       p { | ||||
|         strong { | ||||
|           color: $ui-secondary-color; | ||||
|           font-weight: 700; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     @media screen and (max-width: 675px) { | ||||
|       .container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|       } | ||||
|  | ||||
|       .features { | ||||
|         padding: 20px 0; | ||||
|       } | ||||
|  | ||||
|       .about-mastodon { | ||||
|         order: 1; | ||||
|         flex: 0 0 auto; | ||||
|         max-width: 100%; | ||||
|       } | ||||
|  | ||||
|       #mastodon-timeline { | ||||
|         order: 2; | ||||
|         flex: 0 0 auto; | ||||
|         height: 60vh; | ||||
|       } | ||||
|  | ||||
|       .cta { | ||||
|         margin: 20px 0; | ||||
|         margin-bottom: 30px; | ||||
|       } | ||||
|  | ||||
|       .features-list { | ||||
|         display: none; | ||||
|       } | ||||
|  | ||||
|       .stripe { | ||||
|         display: none; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .stripe { | ||||
|     width: 100%; | ||||
|     height: 360px; | ||||
|     overflow: hidden; | ||||
|     background: darken($ui-base-color, 4%); | ||||
|     position: absolute; | ||||
|     z-index: -1; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @keyframes floating { | ||||
|   | ||||
| @@ -42,6 +42,11 @@ body { | ||||
|     padding-bottom: 0; | ||||
|   } | ||||
|  | ||||
|   &.tag-body { | ||||
|     background: darken($ui-base-color, 8%); | ||||
|     padding-bottom: 0; | ||||
|   } | ||||
|  | ||||
|   &.embed { | ||||
|     background: transparent; | ||||
|     margin: 0; | ||||
|   | ||||
| @@ -66,6 +66,7 @@ | ||||
|     text-transform: none; | ||||
|     background: transparent; | ||||
|     padding: 3px 15px; | ||||
|     border-radius: 4px; | ||||
|     border: 1px solid $ui-primary-color; | ||||
|  | ||||
|     &:active, | ||||
|   | ||||
| @@ -62,7 +62,7 @@ | ||||
|       .about-mastodon | ||||
|         %h3= t 'about.what_is_mastodon' | ||||
|         %p= t 'about.about_mastodon_html' | ||||
|         %a.button.button-secondary{ href: 'https://joinmastodon.org/' }= t 'about.learn_more' | ||||
|         = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary' | ||||
|         = render 'features' | ||||
|   .footer-links | ||||
|     .container | ||||
|   | ||||
							
								
								
									
										6
									
								
								app/views/tags/_og.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								app/views/tags/_og.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname) | ||||
| = opengraph 'og:url', tag_url(@tag) | ||||
| = opengraph 'og:type', 'website' | ||||
| = opengraph 'og:title', "##{@tag.name}" | ||||
| = opengraph 'og:description', t('about.about_hashtag_html', hashtag: @tag.name) | ||||
| = opengraph 'twitter:card', 'summary' | ||||
| @@ -1,19 +1,38 @@ | ||||
| - content_for :page_title do | ||||
|   = "##{@tag.name}" | ||||
|  | ||||
| .compact-header | ||||
|   %h1< | ||||
|     = link_to site_title, root_path | ||||
|     %br | ||||
|     %small ##{@tag.name} | ||||
| - content_for :header_tags do | ||||
|   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) | ||||
|   = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous' | ||||
|   = render 'og' | ||||
|  | ||||
| - if @statuses.empty? | ||||
|   .accounts-grid | ||||
|     = render partial: 'accounts/nothing_here' | ||||
| - else | ||||
|   .activity-stream.h-feed | ||||
|     = render partial: 'stream_entries/status', collection: @statuses, as: :status | ||||
| .landing-page.tag-page | ||||
|   .stripe | ||||
|   .features | ||||
|     .container | ||||
|       #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } } | ||||
|  | ||||
| - if @statuses.size == 20 | ||||
|   .pagination | ||||
|     = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next', rel: 'next' | ||||
|       .about-mastodon | ||||
|         .brand | ||||
|           = link_to root_url do | ||||
|             = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' | ||||
|  | ||||
|         %p= t 'about.about_hashtag_html', hashtag: @tag.name | ||||
|  | ||||
|         .cta | ||||
|           = link_to t('auth.login'), new_user_session_path, class: 'button button-secondary' | ||||
|           = link_to t('about.learn_more'), root_url, class: 'button button-alternative' | ||||
|  | ||||
|         .features-list | ||||
|           .features-list__row | ||||
|             .text | ||||
|               %h6= t 'about.features.not_a_product_title' | ||||
|               = t 'about.features.not_a_product_body' | ||||
|             .visual | ||||
|               = fa_icon 'fw users' | ||||
|           .features-list__row | ||||
|             .text | ||||
|               %h6= t 'about.features.humane_approach_title' | ||||
|               = t 'about.features.humane_approach_body' | ||||
|             .visual | ||||
|               = fa_icon 'fw leaf' | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
| en: | ||||
|   about: | ||||
|     about_mastodon_html: Mastodon is a social network based on open web protocols and free, open-source software. It is decentralized like e-mail. | ||||
|     about_hashtag_html: These are public toots tagged with <strong>#%{hashtag}</strong>. You can interact with them if you have an account anywhere in the fediverse. | ||||
|     about_this: About | ||||
|     closed_registrations: Registrations are currently closed on this instance. However! You can find a different instance to make an account on and get access to the very same network from there. | ||||
|     contact: Contact | ||||
|   | ||||
| @@ -5,9 +5,9 @@ RSpec.describe TagsController, type: :controller do | ||||
|  | ||||
|   describe 'GET #show' do | ||||
|     let!(:tag)     { Fabricate(:tag, name: 'test') } | ||||
|     let!(:local)  { Fabricate(:status, tags: [ tag ], text: 'local #test') } | ||||
|     let!(:remote) { Fabricate(:status, tags: [ tag ], text: 'remote #test', account: Fabricate(:account, domain: 'remote')) } | ||||
|     let!(:late)  { Fabricate(:status, tags: [ tag ], text: 'late #test') } | ||||
|     let!(:local)   { Fabricate(:status, tags: [tag], text: 'local #test') } | ||||
|     let!(:remote)  { Fabricate(:status, tags: [tag], text: 'remote #test', account: Fabricate(:account, domain: 'remote')) } | ||||
|     let!(:late)    { Fabricate(:status, tags: [tag], text: 'late #test') } | ||||
|  | ||||
|     context 'when tag exists' do | ||||
|       it 'returns http success' do | ||||
| @@ -15,41 +15,9 @@ RSpec.describe TagsController, type: :controller do | ||||
|         expect(response).to have_http_status(:success) | ||||
|       end | ||||
|  | ||||
|       it 'renders public layout' do | ||||
|       it 'renders application layout' do | ||||
|         get :show, params: { id: 'test', max_id: late.id } | ||||
|         expect(response).to render_template layout: 'public' | ||||
|       end | ||||
|  | ||||
|       it 'renders only local statuses if local parameter is specified' do | ||||
|         get :show, params: { id: 'test', local: true, max_id: late.id } | ||||
|  | ||||
|         expect(assigns(:tag)).to eq tag | ||||
|         statuses = assigns(:statuses).to_a | ||||
|         expect(statuses.size).to eq 1 | ||||
|         expect(statuses[0]).to eq local | ||||
|       end | ||||
|  | ||||
|       it 'renders local and remote statuses if local parameter is not specified' do | ||||
|         get :show, params: { id: 'test', max_id: late.id } | ||||
|  | ||||
|         expect(assigns(:tag)).to eq tag | ||||
|         statuses = assigns(:statuses).to_a | ||||
|         expect(statuses.size).to eq 2 | ||||
|         expect(statuses[0]).to eq remote | ||||
|         expect(statuses[1]).to eq local | ||||
|       end | ||||
|  | ||||
|       it 'filters statuses by the current account' do | ||||
|         user = Fabricate(:user) | ||||
|         user.account.block!(remote.account) | ||||
|  | ||||
|         sign_in(user) | ||||
|         get :show, params: { id: 'test', max_id: late.id } | ||||
|  | ||||
|         expect(assigns(:tag)).to eq tag | ||||
|         statuses = assigns(:statuses).to_a | ||||
|         expect(statuses.size).to eq 1 | ||||
|         expect(statuses[0]).to eq local | ||||
|         expect(response).to render_template layout: 'application' | ||||
|       end | ||||
|     end | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user