From bc642ac24b49c14dca382e7aabbc16130293d2f4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 16 Jan 2019 19:47:46 +0100 Subject: [PATCH] Redesign public hashtag page to use a masonry layout (#9822) --- app/controllers/tags_controller.rb | 2 + .../mastodon/components/display_name.js | 12 +- app/javascript/mastodon/components/status.js | 2 +- .../standalone/hashtag_timeline/index.js | 86 ++++++--- .../status/components/detailed_status.js | 107 +++++++++-- .../containers/detailed_status_container.js | 172 ++++++++++++++++++ app/javascript/styles/mastodon/widgets.scss | 27 +++ app/views/tags/show.html.haml | 30 +-- package.json | 1 + spec/controllers/tags_controller_spec.rb | 2 +- yarn.lock | 28 +++ 11 files changed, 392 insertions(+), 77 deletions(-) create mode 100644 app/javascript/mastodon/features/status/containers/detailed_status_container.js 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"