diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 8e4051834a..4694c823a4 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -3,6 +3,8 @@
class TagsController < ApplicationController
PAGE_SIZE = 20
+ layout 'public'
+
before_action :set_body_classes
before_action :set_instance_presenter
diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js
index c2c40cb3f5..acddf77c54 100644
--- a/app/javascript/mastodon/components/display_name.js
+++ b/app/javascript/mastodon/components/display_name.js
@@ -1,15 +1,17 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
export default class DisplayName extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
others: ImmutablePropTypes.list,
+ localDomain: PropTypes.string,
};
render () {
- const { account, others } = this.props;
+ const { account, others, localDomain } = this.props;
const displayNameHtml = { __html: account.get('display_name_html') };
let suffix;
@@ -17,7 +19,13 @@ export default class DisplayName extends React.PureComponent {
if (others && others.size > 1) {
suffix = `+${others.size}`;
} else {
- suffix = @{account.get('acct')};
+ let acct = account.get('acct');
+
+ if (acct.indexOf('@') === -1 && localDomain) {
+ acct = `${acct}@${localDomain}`;
+ }
+
+ suffix = @{acct};
}
return (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index fd0780025a..20d8385009 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -77,7 +77,7 @@ class Status extends ImmutablePureComponent {
'account',
'muted',
'hidden',
- ]
+ ];
handleClick = () => {
if (this.props.onClick) {
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index 759922638d..4e09b1948a 100644
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -1,28 +1,32 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
+import ImmutablePropTypes from 'react-immutable-proptypes';
import { expandHashtagTimeline } from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
import { connectHashtagStream } from '../../../actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList } from 'immutable';
+import DetailedStatusContainer from '../../status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from '../../../components/loading_indicator';
-export default @connect()
+const mapStateToProps = (state, { hashtag }) => ({
+ statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
+ isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
+ hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
+});
+
+export default @connect(mapStateToProps)
class HashtagTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ hasMore: PropTypes.bool.isRequired,
hashtag: PropTypes.string.isRequired,
};
- handleHeaderClick = () => {
- this.column.scrollTop();
- }
-
- setRef = c => {
- this.column = c;
- }
-
componentDidMount () {
const { dispatch, hashtag } = this.props;
@@ -37,28 +41,52 @@ class HashtagTimeline extends React.PureComponent {
}
}
- handleLoadMore = maxId => {
- this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+ handleLoadMore = () => {
+ const maxId = this.props.statusIds.last();
+
+ if (maxId) {
+ this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId }));
+ }
}
+ setRef = c => {
+ this.masonry = c;
+ }
+
+ handleHeightChange = debounce(() => {
+ if (!this.masonry) {
+ return;
+ }
+
+ this.masonry.forcePack();
+ }, 50)
+
render () {
- const { hashtag } = this.props;
+ const { statusIds, hasMore, isLoading } = this.props;
+
+ const sizes = [
+ { columns: 1, gutter: 0 },
+ { mq: '415px', columns: 1, gutter: 10 },
+ { mq: '640px', columns: 2, gutter: 10 },
+ { mq: '960px', columns: 3, gutter: 10 },
+ { mq: '1255px', columns: 3, gutter: 10 },
+ ];
+
+ const loader = (isLoading && statusIds.isEmpty()) ? : undefined;
return (
-
-
-
-
-
+
+ {statusIds.map(statusId => (
+
+
+
+ )).toArray()}
+
);
}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b0dea8817b..3ea8e9e748 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
+import scheduleIdleTask from '../../ui/util/schedule_idle_task';
export default class DetailedStatus extends ImmutablePureComponent {
@@ -23,10 +24,17 @@ export default class DetailedStatus extends ImmutablePureComponent {
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
onToggleHidden: PropTypes.func.isRequired,
+ measureHeight: PropTypes.bool,
+ onHeightChange: PropTypes.func,
+ domain: PropTypes.string.isRequired,
+ };
+
+ state = {
+ height: null,
};
handleAccountClick = (e) => {
- if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+ if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
}
@@ -42,13 +50,56 @@ export default class DetailedStatus extends ImmutablePureComponent {
this.props.onToggleHidden(this.props.status);
}
+ _measureHeight (heightJustChanged) {
+ if (this.props.measureHeight && this.node) {
+ scheduleIdleTask(() => this.node && this.setState({ height: this.node.offsetHeight }));
+
+ if (this.props.onHeightChange && heightJustChanged) {
+ this.props.onHeightChange();
+ }
+ }
+ }
+
+ setRef = c => {
+ this.node = c;
+ this._measureHeight();
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ this._measureHeight(prevState.height !== this.state.height);
+ }
+
+ handleModalLink = e => {
+ e.preventDefault();
+
+ let href;
+
+ if (e.target.nodeName !== 'A') {
+ href = e.target.parentNode.href;
+ } else {
+ href = e.target.href;
+ }
+
+ window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+ }
+
render () {
const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+ const outerStyle = { boxSizing: 'border-box' };
+
+ if (!status) {
+ return null;
+ }
let media = '';
let applicationLink = '';
let reblogLink = '';
let reblogIcon = 'retweet';
+ let favouriteLink = '';
+
+ if (this.props.measureHeight) {
+ outerStyle.height = `${this.state.height}px`;
+ }
if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -95,20 +146,51 @@ export default class DetailedStatus extends ImmutablePureComponent {
if (status.get('visibility') === 'private') {
reblogLink = ;
+ } else if (this.context.router) {
+ reblogLink = (
+
+
+
+
+
+
+ );
} else {
- reblogLink = (
-
-
-
-
- );
+ reblogLink = (
+
+
+
+
+
+
+ );
+ }
+
+ if (this.context.router) {
+ favouriteLink = (
+
+
+
+
+
+
+ );
+ } else {
+ favouriteLink = (
+
+
+
+
+
+
+ );
}
return (
-
+
-
+
@@ -118,12 +200,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
- {applicationLink} · {reblogLink} ·
-
-
-
-
-
+ {applicationLink} · {reblogLink} · {favouriteLink}
);
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
new file mode 100644
index 0000000000..2c0db0a6b2
--- /dev/null
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -0,0 +1,172 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from '../../../selectors';
+import {
+ replyCompose,
+ mentionCompose,
+ directCompose,
+} from '../../../actions/compose';
+import {
+ reblog,
+ favourite,
+ unreblog,
+ unfavourite,
+ pin,
+ unpin,
+} from '../../../actions/interactions';
+import { blockAccount } from '../../../actions/accounts';
+import {
+ muteStatus,
+ unmuteStatus,
+ deleteStatus,
+ hideStatus,
+ revealStatus,
+} from '../../../actions/statuses';
+import { initMuteModal } from '../../../actions/mutes';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { boostModal, deleteModal } from '../../../initial_state';
+import { showAlertForError } from '../../../actions/alerts';
+
+const messages = defineMessages({
+ deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+ deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' },
+ redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+ redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+ blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, props) => ({
+ status: getStatus(state, props),
+ domain: state.getIn(['meta', 'domain']),
+ });
+
+ return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onReply (status, router) {
+ dispatch((_, getState) => {
+ let state = getState();
+ if (state.getIn(['compose', 'text']).trim().length !== 0) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: () => dispatch(replyCompose(status, router)),
+ }));
+ } else {
+ dispatch(replyCompose(status, router));
+ }
+ });
+ },
+
+ onModalReblog (status) {
+ dispatch(reblog(status));
+ },
+
+ onReblog (status, e) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !boostModal) {
+ this.onModalReblog(status);
+ } else {
+ dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+ }
+ }
+ },
+
+ onFavourite (status) {
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ },
+
+ onPin (status) {
+ if (status.get('pinned')) {
+ dispatch(unpin(status));
+ } else {
+ dispatch(pin(status));
+ }
+ },
+
+ onEmbed (status) {
+ dispatch(openModal('EMBED', {
+ url: status.get('url'),
+ onError: error => dispatch(showAlertForError(error)),
+ }));
+ },
+
+ onDelete (status, history, withRedraft = false) {
+ if (!deleteModal) {
+ dispatch(deleteStatus(status.get('id'), history, withRedraft));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+ confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+ }));
+ }
+ },
+
+ onDirect (account, router) {
+ dispatch(directCompose(account, router));
+ },
+
+ onMention (account, router) {
+ dispatch(mentionCompose(account, router));
+ },
+
+ onOpenMedia (media, index) {
+ dispatch(openModal('MEDIA', { media, index }));
+ },
+
+ onOpenVideo (media, time) {
+ dispatch(openModal('VIDEO', { media, time }));
+ },
+
+ onBlock (account) {
+ dispatch(openModal('CONFIRM', {
+ message:
@{account.get('acct')} }} />,
+ confirm: intl.formatMessage(messages.blockConfirm),
+ onConfirm: () => dispatch(blockAccount(account.get('id'))),
+ }));
+ },
+
+ onReport (status) {
+ dispatch(initReport(status.get('account'), status));
+ },
+
+ onMute (account) {
+ dispatch(initMuteModal(account));
+ },
+
+ onMuteConversation (status) {
+ if (status.get('muted')) {
+ dispatch(unmuteStatus(status.get('id')));
+ } else {
+ dispatch(muteStatus(status.get('id')));
+ }
+ },
+
+ onToggleHidden (status) {
+ if (status.get('hidden')) {
+ dispatch(revealStatus(status.get('id')));
+ } else {
+ dispatch(hideStatus(status.get('id')));
+ }
+ },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index 87e633c704..cabef807e8 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -425,3 +425,30 @@
border-radius: 0;
}
}
+
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+
+.statuses-grid {
+ min-height: 600px;
+
+ &__item {
+ width: (960px - 20px) / 3;
+
+ @media screen and (max-width: $fluid-breakpoint) {
+ width: (940px - 20px) / 3;
+ }
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ width: 100vw;
+ }
+ }
+
+ .detailed-status {
+ border-radius: 4px;
+
+ @media screen and (max-width: $no-gap-breakpoint) {
+ border-bottom: 1px solid lighten($ui-base-color, 12%);
+ }
+ }
+}
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index f6e452f188..229fe958b8 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -8,33 +8,5 @@
= javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous'
= render 'og'
-.landing-page.tag-page.alternative
- .features
- .container
- .grid
- .column-1
- #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
-
- .column-2
- .about-mastodon
- .about-hashtag.landing-page__information
- .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
- - if user_signed_in?
- = link_to t('settings.back'), root_path, class: 'button button-secondary'
- - else
- = link_to t('auth.login'), new_user_session_path, class: 'button button-secondary'
- = link_to t('about.learn_more'), about_path, class: 'button button-alternative'
-
- .landing-page__features.landing-page__information
- %h3= t 'about.what_is_mastodon'
- %p= t 'about.about_mastodon_html'
-
- = render 'features'
-
+#mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) } }
#modal-container
diff --git a/package.json b/package.json
index 517f52bd4e..5fa6fa8b76 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"react-immutable-proptypes": "^2.1.0",
"react-immutable-pure-component": "^1.1.1",
"react-intl": "^2.7.2",
+ "react-masonry-infinite": "^1.2.2",
"react-motion": "^0.5.2",
"react-notification": "^6.8.4",
"react-overlays": "^0.8.3",
diff --git a/spec/controllers/tags_controller_spec.rb b/spec/controllers/tags_controller_spec.rb
index 33ccaed61c..69def90cf7 100644
--- a/spec/controllers/tags_controller_spec.rb
+++ b/spec/controllers/tags_controller_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe TagsController, type: :controller do
it 'renders application layout' do
get :show, params: { id: 'test', max_id: late.id }
- expect(response).to render_template layout: 'application'
+ expect(response).to render_template layout: 'public'
end
end
diff --git a/yarn.lock b/yarn.lock
index 6f766f2322..9ff12a712b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1681,6 +1681,13 @@ braces@^2.3.0, braces@^2.3.1:
split-string "^3.0.2"
to-regex "^3.0.1"
+bricks.js@^1.7.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/bricks.js/-/bricks.js-1.8.0.tgz#8fdeb3c0226af251f4d5727a7df7f9ac0092b4b2"
+ integrity sha1-j96zwCJq8lH01XJ6fff5rACStLI=
+ dependencies:
+ knot.js "^1.1.5"
+
brorand@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
@@ -5528,6 +5535,11 @@ kleur@^2.0.1:
resolved "https://registry.yarnpkg.com/kleur/-/kleur-2.0.2.tgz#b704f4944d95e255d038f0cb05fb8a602c55a300"
integrity sha512-77XF9iTllATmG9lSlIv0qdQ2BQ/h9t0bJllHlbvsQ0zUWfU7Yi0S8L5JXzPZgkefIiajLmBJJ4BsMJmqcf7oxQ==
+knot.js@^1.1.5:
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/knot.js/-/knot.js-1.1.5.tgz#28e72522f703f50fe98812fde224dd72728fef5d"
+ integrity sha1-KOclIvcD9Q/piBL94iTdcnKP710=
+
lcid@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
@@ -7558,6 +7570,13 @@ react-immutable-pure-component@^1.1.1:
optionalDependencies:
"@types/react" "16.4.6"
+react-infinite-scroller@^1.0.12:
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/react-infinite-scroller/-/react-infinite-scroller-1.2.4.tgz#f67eaec4940a4ce6417bebdd6e3433bfc38826e9"
+ integrity sha512-/oOa0QhZjXPqaD6sictN2edFMsd3kkMiE19Vcz5JDgHpzEJVqYcmq+V3mkwO88087kvKGe1URNksHEOt839Ubw==
+ dependencies:
+ prop-types "^15.5.8"
+
react-input-autosize@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
@@ -7596,6 +7615,15 @@ react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
+react-masonry-infinite@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/react-masonry-infinite/-/react-masonry-infinite-1.2.2.tgz#20c1386f9ccdda9747527c8f42bc2c02dd2e7951"
+ integrity sha1-IME4b5zN2pdHUnyPQrwsAt0ueVE=
+ dependencies:
+ bricks.js "^1.7.0"
+ prop-types "^15.5.10"
+ react-infinite-scroller "^1.0.12"
+
react-motion@^0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/react-motion/-/react-motion-0.5.2.tgz#0dd3a69e411316567927917c6626551ba0607316"