Redesign public hashtag pages (#5237)
This commit is contained in:
parent
d2dee6ea43
commit
f486ef2666
|
@ -1,17 +1,22 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TagsController < ApplicationController
|
class TagsController < ApplicationController
|
||||||
layout 'public'
|
before_action :set_body_classes
|
||||||
|
before_action :set_instance_presenter
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@tag = Tag.find_by!(name: params[:id].downcase)
|
@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)
|
|
||||||
|
|
||||||
respond_to do |format|
|
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
|
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,
|
render json: collection_presenter,
|
||||||
serializer: ActivityPub::CollectionSerializer,
|
serializer: ActivityPub::CollectionSerializer,
|
||||||
adapter: ActivityPub::Adapter,
|
adapter: ActivityPub::Adapter,
|
||||||
|
@ -22,6 +27,14 @@ class TagsController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_body_classes
|
||||||
|
@body_classes = 'tag-body'
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_instance_presenter
|
||||||
|
@instance_presenter = InstancePresenter.new
|
||||||
|
end
|
||||||
|
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: tag_url(@tag),
|
id: tag_url(@tag),
|
||||||
|
@ -30,4 +43,11 @@ class TagsController < ApplicationController
|
||||||
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def initial_state_params
|
||||||
|
{
|
||||||
|
settings: {},
|
||||||
|
token: current_session&.token,
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { hydrateStore } from '../actions/store';
|
||||||
import { IntlProvider, addLocaleData } from 'react-intl';
|
import { IntlProvider, addLocaleData } from 'react-intl';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
import PublicTimeline from '../features/standalone/public_timeline';
|
import PublicTimeline from '../features/standalone/public_timeline';
|
||||||
|
import HashtagTimeline from '../features/standalone/hashtag_timeline';
|
||||||
|
|
||||||
const { localeData, messages } = getLocale();
|
const { localeData, messages } = getLocale();
|
||||||
addLocaleData(localeData);
|
addLocaleData(localeData);
|
||||||
|
@ -22,15 +23,24 @@ export default class TimelineContainer extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
locale: PropTypes.string.isRequired,
|
locale: PropTypes.string.isRequired,
|
||||||
|
hashtag: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { locale } = this.props;
|
const { locale, hashtag } = this.props;
|
||||||
|
|
||||||
|
let timeline;
|
||||||
|
|
||||||
|
if (hashtag) {
|
||||||
|
timeline = <HashtagTimeline hashtag={hashtag} />;
|
||||||
|
} else {
|
||||||
|
timeline = <PublicTimeline />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntlProvider locale={locale} messages={messages}>
|
<IntlProvider locale={locale} messages={messages}>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PublicTimeline />
|
{timeline}
|
||||||
</Provider>
|
</Provider>
|
||||||
</IntlProvider>
|
</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() {
|
function loaded() {
|
||||||
const TimelineContainer = require('../mastodon/containers/timeline_container').default;
|
const TimelineContainer = require('../mastodon/containers/timeline_container').default;
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const ReactDOM = require('react-dom');
|
const ReactDOM = require('react-dom');
|
||||||
const mountNode = document.getElementById('mastodon-timeline');
|
const mountNode = document.getElementById('mastodon-timeline');
|
||||||
|
|
||||||
if (mountNode !== null) {
|
if (mountNode !== null) {
|
||||||
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
const props = JSON.parse(mountNode.getAttribute('data-props'));
|
||||||
|
|
|
@ -481,6 +481,7 @@
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: $ui-base-color;
|
background: $ui-base-color;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
box-shadow: 0 0 6px rgba($black, 0.1);
|
box-shadow: 0 0 6px rgba($black, 0.1);
|
||||||
|
|
||||||
.column-header {
|
.column-header {
|
||||||
|
@ -703,9 +704,99 @@
|
||||||
.features #mastodon-timeline {
|
.features #mastodon-timeline {
|
||||||
height: 70vh;
|
height: 70vh;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 330px;
|
||||||
margin-bottom: 50px;
|
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 {
|
@keyframes floating {
|
||||||
|
|
|
@ -42,6 +42,11 @@ body {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.tag-body {
|
||||||
|
background: darken($ui-base-color, 8%);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.embed {
|
&.embed {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
@ -66,6 +66,7 @@
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 3px 15px;
|
padding: 3px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
border: 1px solid $ui-primary-color;
|
border: 1px solid $ui-primary-color;
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
|
|
|
@ -62,7 +62,7 @@
|
||||||
.about-mastodon
|
.about-mastodon
|
||||||
%h3= t 'about.what_is_mastodon'
|
%h3= t 'about.what_is_mastodon'
|
||||||
%p= t 'about.about_mastodon_html'
|
%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'
|
= render 'features'
|
||||||
.footer-links
|
.footer-links
|
||||||
.container
|
.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
|
- content_for :page_title do
|
||||||
= "##{@tag.name}"
|
= "##{@tag.name}"
|
||||||
|
|
||||||
.compact-header
|
- content_for :header_tags do
|
||||||
%h1<
|
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
|
||||||
= link_to site_title, root_path
|
= javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
|
||||||
%br
|
= render 'og'
|
||||||
%small ##{@tag.name}
|
|
||||||
|
|
||||||
- if @statuses.empty?
|
.landing-page.tag-page
|
||||||
.accounts-grid
|
.stripe
|
||||||
= render partial: 'accounts/nothing_here'
|
.features
|
||||||
- else
|
.container
|
||||||
.activity-stream.h-feed
|
#mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
|
||||||
= render partial: 'stream_entries/status', collection: @statuses, as: :status
|
|
||||||
|
|
||||||
- if @statuses.size == 20
|
.about-mastodon
|
||||||
.pagination
|
.brand
|
||||||
= link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next', rel: 'next'
|
= 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:
|
en:
|
||||||
about:
|
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_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
|
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.
|
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
|
contact: Contact
|
||||||
|
|
|
@ -5,9 +5,9 @@ RSpec.describe TagsController, type: :controller do
|
||||||
|
|
||||||
describe 'GET #show' do
|
describe 'GET #show' do
|
||||||
let!(:tag) { Fabricate(:tag, name: 'test') }
|
let!(:tag) { Fabricate(:tag, name: 'test') }
|
||||||
let!(:local) { Fabricate(:status, tags: [ tag ], text: 'local #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!(:remote) { Fabricate(:status, tags: [tag], text: 'remote #test', account: Fabricate(:account, domain: 'remote')) }
|
||||||
let!(:late) { Fabricate(:status, tags: [ tag ], text: 'late #test') }
|
let!(:late) { Fabricate(:status, tags: [tag], text: 'late #test') }
|
||||||
|
|
||||||
context 'when tag exists' do
|
context 'when tag exists' do
|
||||||
it 'returns http success' do
|
it 'returns http success' do
|
||||||
|
@ -15,41 +15,9 @@ RSpec.describe TagsController, type: :controller do
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders public layout' do
|
it 'renders application layout' do
|
||||||
get :show, params: { id: 'test', max_id: late.id }
|
get :show, params: { id: 'test', max_id: late.id }
|
||||||
expect(response).to render_template layout: 'public'
|
expect(response).to render_template layout: 'application'
|
||||||
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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Reference in a new issue