diff options
Diffstat (limited to 'app')
33 files changed, 617 insertions, 142 deletions
diff --git a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb index 4315b0283..6851099f6 100644 --- a/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb +++ b/app/controllers/api/v1/statuses/reblogged_by_accounts_controller.rb @@ -25,7 +25,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController end def paginated_statuses - Status.where(reblog_of_id: @status.id).paginate_by_max_id( + Status.where(reblog_of_id: @status.id).where(visibility: [:public, :unlisted]).paginate_by_max_id( limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id] diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index d06f4e070..186d276c2 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/common.js b/app/javascript/mastodon/common.js index 2b10b8c30..fba21316a 100644 --- a/app/javascript/mastodon/common.js +++ b/app/javascript/mastodon/common.js @@ -4,5 +4,9 @@ export function start() { require('font-awesome/css/font-awesome.css'); require.context('../images/', true); - Rails.start(); + try { + Rails.start(); + } catch (e) { + // If called twice + } }; diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index c2c40cb3f..acddf77c5 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 = <span className='display-name__account'>@{account.get('acct')}</span>; + let acct = account.get('acct'); + + if (acct.indexOf('@') === -1 && localDomain) { + acct = `${acct}@${localDomain}`; + } + + suffix = <span className='display-name__account'>@{acct}</span>; } return ( diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index fd0780025..20d838500 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 759922638..333726f94 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()) ? <LoadingIndicator key={0} /> : undefined; return ( - <Column ref={this.setRef}> - <ColumnHeader - icon='hashtag' - title={hashtag} - onClick={this.handleHeaderClick} - /> - - <StatusListContainer - trackScroll={false} - scrollKey='standalone_hashtag_timeline' - timelineId={`hashtag:${hashtag}`} - onLoadMore={this.handleLoadMore} - /> - </Column> + <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}> + {statusIds.map(statusId => ( + <div className='statuses-grid__item' key={statusId}> + <DetailedStatusContainer + id={statusId} + compact + measureHeight + onHeightChange={this.handleHeightChange} + /> + </div> + )).toArray()} + </Masonry> ); } diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index b0dea8817..0630387d2 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -11,6 +11,8 @@ 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'; +import classNames from 'classnames'; export default class DetailedStatus extends ImmutablePureComponent { @@ -23,10 +25,18 @@ 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, + compact: PropTypes.bool, + }; + + 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 +52,57 @@ 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: Math.ceil(this.node.scrollHeight) + 1 })); + + 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' }; + const { compact } = this.props; + + 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,35 +149,63 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('visibility') === 'private') { reblogLink = <i className={`fa fa-${reblogIcon}`} />; + } else if (this.context.router) { + reblogLink = ( + <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> + <i className={`fa fa-${reblogIcon}`} /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </Link> + ); } else { - reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'> - <i className={`fa fa-${reblogIcon}`} /> - <span className='detailed-status__reblogs'> - <FormattedNumber value={status.get('reblogs_count')} /> - </span> - </Link>); + reblogLink = ( + <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> + <i className={`fa fa-${reblogIcon}`} /> + <span className='detailed-status__reblogs'> + <FormattedNumber value={status.get('reblogs_count')} /> + </span> + </a> + ); } - return ( - <div className='detailed-status'> - <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> - <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> - <DisplayName account={status.get('account')} /> + if (this.context.router) { + favouriteLink = ( + <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> + </Link> + ); + } else { + favouriteLink = ( + <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}> + <i className='fa fa-star' /> + <span className='detailed-status__favorites'> + <FormattedNumber value={status.get('favourites_count')} /> + </span> </a> + ); + } - <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} /> - - {media} - - <div className='detailed-status__meta'> - <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> - <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> - </a>{applicationLink} · {reblogLink} · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'> - <i className='fa fa-star' /> - <span className='detailed-status__favorites'> - <FormattedNumber value={status.get('favourites_count')} /> - </span> - </Link> + return ( + <div style={outerStyle}> + <div ref={this.setRef} className={classNames('detailed-status', { compact })}> + <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'> + <div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div> + <DisplayName account={status.get('account')} localDomain={this.props.domain} /> + </a> + + <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} /> + + {media} + + <div className='detailed-status__meta'> + <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> + <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' /> + </a>{applicationLink} · {reblogLink} · {favouriteLink} + </div> </div> </div> ); 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 000000000..2c0db0a6b --- /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: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + 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/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index a092f7bb1..d48b682eb 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -101,6 +101,7 @@ const makeMapStateToProps = () => { ancestorsIds, descendantsIds, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, + domain: state.getIn(['meta', 'domain']), }; }; @@ -123,6 +124,7 @@ class Status extends ImmutablePureComponent { descendantsIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, askReplyConfirmation: PropTypes.bool, + domain: PropTypes.string.isRequired, }; state = { @@ -387,7 +389,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl } = this.props; + const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props; const { fullscreen } = this.state; if (status === null) { @@ -438,6 +440,7 @@ class Status extends ImmutablePureComponent { onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} onToggleHidden={this.handleToggleHidden} + domain={domain} /> <ActionBar diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index ed338c2eb..b7e350cbc 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -33,7 +33,7 @@ const messages = defineMessages({ publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, }); -const shouldHideFAB = path => path.match(/^\/statuses\//); +const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/); export default @(component => injectIntl(component, { withRef: true })) class ColumnsArea extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index a59c0a257..f01c2bf24 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -243,6 +243,7 @@ class UI extends React.PureComponent { } handleDragOver = (e) => { + if (this.dataTransferIsText(e.dataTransfer)) return false; e.preventDefault(); e.stopPropagation(); @@ -256,6 +257,7 @@ class UI extends React.PureComponent { } handleDrop = (e) => { + if (this.dataTransferIsText(e.dataTransfer)) return; e.preventDefault(); this.setState({ draggingOver: false }); @@ -279,6 +281,10 @@ class UI extends React.PureComponent { this.setState({ draggingOver: false }); } + dataTransferIsText = (dataTransfer) => { + return (dataTransfer && Array.from(dataTransfer.types).includes('text/plain') && dataTransfer.items.length === 1); + } + closeUploadModal = () => { this.setState({ draggingOver: false }); } diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 302ff2573..11c31877c 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -8,7 +8,7 @@ "account.disclaimer_full": "La informació següent pot reflectir incompleta el perfil de l'usuari.", "account.domain_blocked": "Domini ocult", "account.edit_profile": "Editar el perfil", - "account.endorse": "Característica del perfil", + "account.endorse": "Recomanar en el teu perfil", "account.follow": "Segueix", "account.followers": "Seguidors", "account.followers.empty": "Encara ningú no segueix aquest usuari.", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 471b70439..f35b96244 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -84,7 +84,7 @@ "confirmations.block.confirm": "Blocio", "confirmations.block.message": "Ydych chi'n sicr eich bod eisiau blocio {name}?", "confirmations.delete.confirm": "Dileu", - "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y statws hwn?", + "confirmations.delete.message": "Ydych chi'n sicr eich bod eisiau dileu y tŵt hwn?", "confirmations.delete_list.confirm": "Dileu", "confirmations.delete_list.message": "Ydych chi'n sicr eich bod eisiau dileu y rhestr hwn am byth?", "confirmations.domain_block.confirm": "Cuddio parth cyfan", @@ -92,12 +92,12 @@ "confirmations.mute.confirm": "Tawelu", "confirmations.mute.message": "Ydych chi'n sicr eich bod am ddistewi {name}?", "confirmations.redraft.confirm": "Dileu & ailddrafftio", - "confirmations.redraft.message": "Ydych chi'n siwr eich bod eisiau dileu y statws hwn a'i ailddrafftio? Bydd ffefrynnau a bwstiau'n cael ei colli, a bydd ymatebion i'r statws gwreiddiol yn cael eu hamddifadu.", + "confirmations.redraft.message": "Ydych chi'n siwr eich bod eisiau dileu y tŵt hwn a'i ailddrafftio? Bydd ffefrynnau a bwstiau'n cael ei colli, a bydd ymatebion i'r tŵt gwreiddiol yn cael eu hamddifadu.", "confirmations.reply.confirm": "Ateb", "confirmations.reply.message": "Bydd ateb nawr yn cymryd lle y neges yr ydych yn cyfansoddi ar hyn o bryd. Ydych chi'n sicr yr ydych am barhau?", "confirmations.unfollow.confirm": "Dad-ddilynwch", "confirmations.unfollow.message": "Ydych chi'n sicr eich bod am ddad-ddilyn {name}?", - "embed.instructions": "Mewnblannwch y statws hwn ar eich gwefan drwy gopïo'r côd isod.", + "embed.instructions": "Mewnblannwch y tŵt hwn ar eich gwefan drwy gopïo'r côd isod.", "embed.preview": "Dyma sut olwg fydd arno:", "emoji_button.activity": "Gweithgarwch", "emoji_button.custom": "Unigryw", @@ -169,12 +169,12 @@ "keyboard_shortcuts.back": "i lywio nôl", "keyboard_shortcuts.blocked": "i agor rhestr defnyddwyr a flociwyd", "keyboard_shortcuts.boost": "i fŵstio", - "keyboard_shortcuts.column": "i ffocysu statws yn un o'r colofnau", + "keyboard_shortcuts.column": "i ffocysu tŵt yn un o'r colofnau", "keyboard_shortcuts.compose": "i ffocysu yr ardal cyfansoddi testun", "keyboard_shortcuts.description": "Disgrifiad", "keyboard_shortcuts.direct": "i agor colofn negeseuon preifat", "keyboard_shortcuts.down": "i symud lawr yn y rhestr", - "keyboard_shortcuts.enter": "i agor statws", + "keyboard_shortcuts.enter": "i agor tŵt", "keyboard_shortcuts.favourite": "i hoffi", "keyboard_shortcuts.favourites": "i agor rhestr hoffi", "keyboard_shortcuts.federated": "i agor ffrwd y ffederasiwn", @@ -234,10 +234,10 @@ "navigation_bar.preferences": "Dewisiadau", "navigation_bar.public_timeline": "Ffrwd y ffederasiwn", "navigation_bar.security": "Diogelwch", - "notification.favourite": "hoffodd {name} eich statws", + "notification.favourite": "hoffodd {name} eich tŵt", "notification.follow": "dilynodd {name} chi", "notification.mention": "Soniodd {name} amdanoch chi", - "notification.reblog": "{name} boosted your status", + "notification.reblog": "Hysbysebodd {name} eich tŵt", "notifications.clear": "Clirio hysbysiadau", "notifications.clear_confirmation": "Ydych chi'n sicr eich bod am glirio'ch holl hysbysiadau am byth?", "notifications.column_settings.alert": "Hysbysiadau bwrdd gwaith", @@ -257,7 +257,7 @@ "notifications.filter.follows": "Yn dilyn", "notifications.filter.mentions": "Mentions", "notifications.group": "{count} o hysbysiadau", - "privacy.change": "Addasu preifatrwdd y statws", + "privacy.change": "Addasu preifatrwdd y tŵt", "privacy.direct.long": "Cyhoeddi i'r defnyddwyr sy'n cael eu crybwyll yn unig", "privacy.direct.short": "Uniongyrchol", "privacy.private.long": "Cyhoeddi i ddilynwyr yn unig", @@ -284,7 +284,7 @@ "search_popout.search_format": "Fformat chwilio uwch", "search_popout.tips.full_text": "Mae testun syml yn dychwelyd tŵtiau yr ydych wedi ysgrifennu, hoffi, wedi'u bŵstio, neu wedi'ch crybwyll ynddynt, ynghyd a chyfateb a enwau defnyddwyr, enwau arddangos ac hashnodau.", "search_popout.tips.hashtag": "hashnod", - "search_popout.tips.status": "statws", + "search_popout.tips.status": "tŵt", "search_popout.tips.text": "Mae testun syml yn dychwelyd enwau arddangos, enwau defnyddwyr a hashnodau sy'n cyfateb", "search_popout.tips.user": "defnyddiwr", "search_results.accounts": "Pobl", @@ -293,7 +293,7 @@ "search_results.total": "{count, number} {count, plural, one {result} other {results}}", "standalone.public_title": "Golwg tu fewn...", "status.admin_account": "Open moderation interface for @{name}", - "status.admin_status": "Open this status in the moderation interface", + "status.admin_status": "Open this tŵt in the moderation interface", "status.block": "Blocio @{name}", "status.cancel_reblog_private": "Dadfŵstio", "status.cannot_reblog": "Ni ellir sbarduno'r tŵt hwn", @@ -309,7 +309,7 @@ "status.more": "Mwy", "status.mute": "Tawelu @{name}", "status.mute_conversation": "Tawelu sgwrs", - "status.open": "Ehangu'r statws hwn", + "status.open": "Ehangu'r tŵt hwn", "status.pin": "Pinio ar y proffil", "status.pinned": "Pinio tŵt", "status.read_more": "Darllen mwy", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index b4181ea05..60f481076 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -1973,6 +1973,43 @@ "id": "confirmations.block.confirm" }, { + "defaultMessage": "Reply", + "id": "confirmations.reply.confirm" + }, + { + "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "id": "confirmations.reply.message" + }, + { + "defaultMessage": "Are you sure you want to block {name}?", + "id": "confirmations.block.message" + } + ], + "path": "app/javascript/mastodon/features/status/containers/detailed_status_container.json" + }, + { + "descriptors": [ + { + "defaultMessage": "Delete", + "id": "confirmations.delete.confirm" + }, + { + "defaultMessage": "Are you sure you want to delete this status?", + "id": "confirmations.delete.message" + }, + { + "defaultMessage": "Delete & redraft", + "id": "confirmations.redraft.confirm" + }, + { + "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.", + "id": "confirmations.redraft.message" + }, + { + "defaultMessage": "Block", + "id": "confirmations.block.confirm" + }, + { "defaultMessage": "Show more for all", "id": "status.show_more_all" }, diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index f213f8ea3..a1a514f49 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -132,7 +132,7 @@ "follow_request.authorize": "Autorizează", "follow_request.reject": "Respinge", "getting_started.developers": "Dezvoltatori", - "getting_started.directory": "Directorul profilului", + "getting_started.directory": "Explorează", "getting_started.documentation": "Documentație", "getting_started.heading": "Începe", "getting_started.invite": "Invită prieteni", diff --git a/app/javascript/mastodon/locales/sk.json b/app/javascript/mastodon/locales/sk.json index 91ecbbce7..a3467785a 100644 --- a/app/javascript/mastodon/locales/sk.json +++ b/app/javascript/mastodon/locales/sk.json @@ -70,7 +70,7 @@ "compose_form.direct_message_warning": "Tento príspevok bude videný výhradne iba spomenutými užívateľmi. Ber ale na vedomie že správci tvojej a všetkých iných zahrnutých instancií majú možnosť skontrolovať túto správu.", "compose_form.direct_message_warning_learn_more": "Zistiť viac", "compose_form.hashtag_warning": "Tento toot nebude zobrazený pod žiadným haštagom lebo nieje listovaný. Iba verejné tooty môžu byť nájdené podľa haštagu.", - "compose_form.lock_disclaimer": "Váš účet nie je zamknutý. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.", + "compose_form.lock_disclaimer": "Váš účet nie je {locked}. Ktokoľvek ťa môže nasledovať a vidieť tvoje správy pre sledujúcich.", "compose_form.lock_disclaimer.lock": "zamknutý", "compose_form.placeholder": "Čo máš na mysli?", "compose_form.publish": "Pošli", diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 69441d315..69e6ba0ec 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -91,6 +91,14 @@ function main() { if (parallaxComponents.length > 0 ) { new Rellax('.parallax', { speed: -1 }); } + + if (document.body.classList.contains('with-modals')) { + const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; + const scrollbarWidthStyle = document.createElement('style'); + scrollbarWidthStyle.id = 'scrollbar-width'; + document.head.appendChild(scrollbarWidthStyle); + scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0); + } }); } diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index 47ac9265b..c6f249fab 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -364,7 +364,7 @@ $small-breakpoint: 960px; @media screen and (max-width: $column-breakpoint) { .grid { - grid-template-columns: auto; + grid-template-columns: 100%; .column-0 { display: block; diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 8de53ca98..a98fa52c4 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -295,7 +295,7 @@ color: $primary-text-color; } - @media screen and (max-width: $no-gap-breakpoint) { + @media screen and (max-width: 550px) { &.optional { display: none; } diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 6132dd1ae..bab982706 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -419,7 +419,7 @@ code { background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") no-repeat right 8px center / auto 16px; border: 1px solid darken($ui-base-color, 14%); border-radius: 4px; - padding: 10px; + padding-left: 10px; padding-right: 30px; height: 41px; } diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index 87e633c70..c97337e4e 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -425,3 +425,93 @@ border-radius: 0; } } + +$maximum-width: 1235px; +$fluid-breakpoint: $maximum-width + 20px; + +.statuses-grid { + min-height: 600px; + + @media screen and (max-width: 640px) { + width: 100% !important; // Masonry layout is unnecessary at this width + } + + &__item { + width: (960px - 20px) / 3; + + @media screen and (max-width: $fluid-breakpoint) { + width: (940px - 20px) / 3; + } + + @media screen and (max-width: 640px) { + width: 100%; + } + + @media screen and (max-width: $no-gap-breakpoint) { + width: 100vw; + } + } + + .detailed-status { + border-radius: 4px; + + @media screen and (max-width: $no-gap-breakpoint) { + border-top: 1px solid lighten($ui-base-color, 16%); + } + + &.compact { + .detailed-status__meta { + margin-top: 15px; + } + + .status__content { + font-size: 15px; + line-height: 20px; + + .emojione { + width: 20px; + height: 20px; + margin: -3px 0 0; + } + + .status__content__spoiler-link { + line-height: 20px; + margin: 0; + } + } + + .media-gallery, + .status-card, + .video-player { + margin-top: 15px; + } + } + } +} + +.notice-widget { + margin-bottom: 10px; + color: $darker-text-color; + + p { + margin-bottom: 10px; + + &:last-child { + margin-bottom: 0; + } + } + + a { + font-size: 14px; + line-height: 20px; + text-decoration: none; + font-weight: 500; + color: $ui-highlight-color; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } +} diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 1147a4481..34d1b7cbd 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -17,7 +17,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity uri: @json['id'], created_at: @json['published'], override_timestamps: @options[:override_timestamps], - visibility: original_status.visibility + visibility: visibility_from_audience ) distribute(status) @@ -26,6 +26,18 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity private + def visibility_from_audience + if equals_or_includes?(@json['to'], ActivityPub::TagManager::COLLECTIONS[:public]) + :public + elsif equals_or_includes?(@json['cc'], ActivityPub::TagManager::COLLECTIONS[:public]) + :unlisted + elsif equals_or_includes?(@json['to'], @account.followers_url) + :private + else + :direct + end + end + def announceable?(status) status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility? end diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 2b238bc88..b49657d4b 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -5,10 +5,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity CONVERTED_TYPES = %w(Image Video Article Page).freeze def perform - return if delete_arrived_first?(object_uri) || unsupported_object_type? || invalid_origin?(@object['id']) + return if unsupported_object_type? || invalid_origin?(@object['id']) + return if Tombstone.exists?(uri: @object['id']) RedisLock.acquire(lock_options) do |lock| if lock.acquired? + return if delete_arrived_first?(object_uri) + @status = find_existing_status if @status.nil? @@ -59,7 +62,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity account: @account, text: text_from_content || '', language: detected_language, - spoiler_text: text_from_summary || '', + spoiler_text: converted_object_type? ? '' : (text_from_summary || ''), created_at: @object['published'], override_timestamps: @options[:override_timestamps], reply: @object['inReplyTo'].present?, @@ -254,7 +257,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end def text_from_content - return Formatter.instance.linkify([text_from_name, object_url || @object['id']].join(' ')) if converted_object_type? + return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || @object['id']].join(' ')) if converted_object_type? if @object['content'].present? @object['content'] diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 8270fed1b..dc76dd3e2 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -21,11 +21,14 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def delete_note return if object_uri.nil? + unless invalid_origin?(object_uri) + RedisLock.acquire(lock_options) { |_lock| delete_later!(object_uri) } + Tombstone.find_or_create_by(uri: object_uri, account: @account) + end + @status = Status.find_by(uri: object_uri, account: @account) @status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present? - delete_later!(object_uri) - return if @status.nil? if @status.public_visibility? || @status.unlisted_visibility? @@ -68,4 +71,17 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity def payload @payload ||= Oj.dump(@json) end + + def lock_options + { redis: Redis.current, key: "create:#{object_uri}" } + end + + def invalid_origin?(url) + return true if unsupported_uri_scheme?(url) + + needle = Addressable::URI.parse(url).host + haystack = Addressable::URI.parse(@account.uri).host + + !haystack.casecmp(needle).zero? + end end diff --git a/app/models/status.rb b/app/models/status.rb index e709b16c8..4566c0d20 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -504,7 +504,7 @@ class Status < ApplicationRecord return if direct_visibility? account&.increment_count!(:statuses_count) - reblog&.increment_count!(:reblogs_count) if reblog? + reblog&.increment_count!(:reblogs_count) if reblog? && (public_visibility? || unlisted_visibility?) thread&.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?) end @@ -512,7 +512,7 @@ class Status < ApplicationRecord return if direct_visibility? || marked_for_mass_destruction? account&.decrement_count!(:statuses_count) - reblog&.decrement_count!(:reblogs_count) if reblog? + reblog&.decrement_count!(:reblogs_count) if reblog? && (public_visibility? || unlisted_visibility?) thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?) end diff --git a/app/models/tombstone.rb b/app/models/tombstone.rb new file mode 100644 index 000000000..997bb65fd --- /dev/null +++ b/app/models/tombstone.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: tombstones +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# uri :string not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class Tombstone < ApplicationRecord + belongs_to :account +end diff --git a/app/models/user.rb b/app/models/user.rb index 83ccce948..0425c1772 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -362,7 +362,8 @@ class User < ApplicationRecord end def regenerate_feed! - Redis.current.setnx("account:#{account_id}:regeneration", true) && Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds) + return unless Redis.current.setnx("account:#{account_id}:regeneration", true) + Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds) RegenerationWorker.perform_async(account_id) end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index d6c791b44..487456f3a 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -33,6 +33,8 @@ class ActivityPub::ProcessAccountService < BaseService after_protocol_change! if protocol_changed? after_key_change! if key_changed? && !@options[:signed_with_known_key] + clear_tombstones! if key_changed? + unless @options[:only_key] check_featured_collection! if @account.featured_collection_url.present? check_links! unless @account.fields.empty? @@ -209,6 +211,10 @@ class ActivityPub::ProcessAccountService < BaseService !@old_public_key.nil? && @old_public_key != @account.public_key end + def clear_tombstones! + Tombstone.delete_all(account_id: @account.id) + end + def protocol_changed? !@old_protocol.nil? && @old_protocol != @account.protocol end diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 4f771ff72..076dedaca 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -3,6 +3,7 @@ class PrecomputeFeedService < BaseService def call(account) FeedManager.instance.populate_feed(account) + ensure Redis.current.del("account:#{account.id}:regeneration") end end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 03e45912d..95da2a667 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -20,6 +20,7 @@ class UnfollowService < BaseService follow.destroy! create_notification(follow) unless @target_account.local? + create_reject_notification(follow) if @target_account.local? && !@source_account.local? UnmergeWorker.perform_async(@target_account.id, @source_account.id) follow end @@ -42,6 +43,12 @@ class UnfollowService < BaseService end end + def create_reject_notification(follow) + # Rejecting an already-existing follow request + return unless follow.account.activitypub? + ActivityPub::DeliveryWorker.perform_async(build_reject_json(follow), follow.target_account_id, follow.account.inbox_url) + end + def build_json(follow) ActiveModelSerializers::SerializableResource.new( follow, @@ -50,6 +57,14 @@ class UnfollowService < BaseService ).to_json end + def build_reject_json(follow) + ActiveModelSerializers::SerializableResource.new( + follow, + serializer: ActivityPub::RejectFollowSerializer, + adapter: ActivityPub::Adapter + ).to_json + end + def build_xml(follow) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.unfollow_salmon(follow)) end diff --git a/app/views/directories/index.html.haml b/app/views/directories/index.html.haml index 9e1e65732..6608a5dcb 100644 --- a/app/views/directories/index.html.haml +++ b/app/views/directories/index.html.haml @@ -41,8 +41,22 @@ = paginate @accounts .column-1 - - if @tags.empty? - .nothing-here.nothing-here--flexible + - if user_signed_in? + .box-widget.notice-widget + - if current_account.discoverable? + - if current_account.followers_count < Account::MIN_FOLLOWERS_DISCOVERY + %p= t('directories.enabled_but_waiting', min_followers: Account::MIN_FOLLOWERS_DISCOVERY) + - else + %p= t('directories.enabled') + - else + %p= t('directories.how_to_enable') + + = link_to settings_profile_path do + = t('settings.edit_profile') + = fa_icon 'chevron-right fw' + + - if @tags.empty? && !user_signed_in? + .nothing-here - else - @tags.each do |tag| .directory__tag{ class: tag.id == @tag&.id ? 'active' : nil } diff --git a/app/views/tags/_features.html.haml b/app/views/tags/_features.html.haml deleted file mode 100644 index 8fbc6b760..000000000 --- a/app/views/tags/_features.html.haml +++ /dev/null @@ -1,25 +0,0 @@ -.features-list - .features-list__row - .text - %h6= t 'about.features.real_conversation_title' - = t 'about.features.real_conversation_body' - .visual - = fa_icon 'fw comments' - .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.within_reach_title' - = t 'about.features.within_reach_body' - .visual - = fa_icon 'fw mobile' - .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/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index 2b46e58c7..850160ac1 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -7,33 +7,9 @@ %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) = 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' +.page-header + %h1= "##{@tag.name}" + %p= t('about.about_hashtag_html', hashtag: @tag.name) +#mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name)) }} #modal-container |