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,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 {
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