@@ -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,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; | |||
} | |||
} | |||
@@ -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 | |||
@@ -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 | |||