diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 240ef058af..9f3090e37b 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -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 diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js index 6b545ef092..4be0379559 100644 --- a/app/javascript/mastodon/containers/timeline_container.js +++ b/app/javascript/mastodon/containers/timeline_container.js @@ -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 = ; + } else { + timeline = ; + } return ( - + {timeline} ); diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js new file mode 100644 index 0000000000..f15fbb2f40 --- /dev/null +++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js @@ -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 ( + + + + + + ); + } + +} diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js index 6705377c10..50c81198e3 100644 --- a/app/javascript/packs/about.js +++ b/app/javascript/packs/about.js @@ -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')); diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index 2adcb5ba2d..a15afc32c9 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -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,8 +704,98 @@ .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; } } diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss index 0018c9a5d1..500e506f69 100644 --- a/app/javascript/styles/basics.scss +++ b/app/javascript/styles/basics.scss @@ -42,6 +42,11 @@ body { padding-bottom: 0; } + &.tag-body { + background: darken($ui-base-color, 8%); + padding-bottom: 0; + } + &.embed { background: transparent; margin: 0; diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 6c64528d6b..0e7022e9b7 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -66,6 +66,7 @@ text-transform: none; background: transparent; padding: 3px 15px; + border-radius: 4px; border: 1px solid $ui-primary-color; &:active, diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index 0d311b895f..ef27d07a11 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -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 diff --git a/app/views/tags/_og.html.haml b/app/views/tags/_og.html.haml new file mode 100644 index 0000000000..853a499aea --- /dev/null +++ b/app/views/tags/_og.html.haml @@ -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' diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index 8cd2f1825f..6266d3c0c5 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -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' diff --git a/config/locales/en.yml b/config/locales/en.yml index 2059c5e2be..82041be24e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 #%{hashtag}. 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 diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb index 3f46c14c0d..b04666c0ff 100644 --- a/spec/controllers/tags_controller_spec.rb +++ b/spec/controllers/tags_controller_spec.rb @@ -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