about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/tags_controller.rb30
-rw-r--r--app/javascript/mastodon/containers/timeline_container.js14
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js70
-rw-r--r--app/javascript/packs/about.js6
-rw-r--r--app/javascript/styles/about.scss91
-rw-r--r--app/javascript/styles/basics.scss5
-rw-r--r--app/javascript/styles/components.scss1
-rw-r--r--app/views/about/show.html.haml2
-rw-r--r--app/views/tags/_og.html.haml6
-rw-r--r--app/views/tags/show.html.haml47
-rw-r--r--config/locales/en.yml1
-rw-r--r--spec/controllers/tags_controller_spec.rb42
12 files changed, 253 insertions, 62 deletions
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 240ef058a..9f3090e37 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 6b545ef09..4be037955 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 = <HashtagTimeline hashtag={hashtag} />;
+    } else {
+      timeline = <PublicTimeline />;
+    }
 
     return (
       <IntlProvider locale={locale} messages={messages}>
         <Provider store={store}>
-          <PublicTimeline />
+          {timeline}
         </Provider>
       </IntlProvider>
     );
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 000000000..f15fbb2f4
--- /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 (
+      <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>
+    );
+  }
+
+}
diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js
index 6705377c1..50c81198e 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 2adcb5ba2..a15afc32c 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 0018c9a5d..500e506f6 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 6c64528d6..0e7022e9b 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 0d311b895..ef27d07a1 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 000000000..853a499ae
--- /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 8cd2f1825..6266d3c0c 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 2059c5e2b..82041be24 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 <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
diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb
index 3f46c14c0..b04666c0f 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