# frozen_string_literal: true
class Api::V1::SearchController < Api::BaseController
- RESULTS_LIMIT = 5
+ include Authorization
+
+ RESULTS_LIMIT = 10
before_action -> { doorkeeper_authorize! :read }
before_action :require_user!
--- /dev/null
- me : PropTypes.string.isRequired,
+ /*
+
+ `<AccountHeader>`
+ =================
+
+ > For more information on the contents of this file, please contact:
+ >
+ > - kibigo! [@kibi@glitch.social]
+
+ Original file by @gargron@mastodon.social et al as part of
+ tootsuite/mastodon. We've expanded it in order to handle user bio
+ frontmatter.
+
+ The `<AccountHeader>` component provides the header for account
+ timelines. It is a fairly simple component which mostly just consists
+ of a `render()` method.
+
+ __Props:__
+
+ - __`account` (`ImmutablePropTypes.map`) :__
+ The account to render a header for.
+
+ - __`me` (`PropTypes.number.isRequired`) :__
+ The id of the currently-signed-in account.
+
+ - __`onFollow` (`PropTypes.func.isRequired`) :__
+ The function to call when the user clicks the "follow" button.
+
+ - __`intl` (`PropTypes.object.isRequired`) :__
+ Our internationalization object, inserted by `@injectIntl`.
+
+ */
+
+ // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+ /*
+
+ Imports:
+ --------
+
+ */
+
+ // Package imports //
+ import React from 'react';
+ import ImmutablePropTypes from 'react-immutable-proptypes';
+ import PropTypes from 'prop-types';
+ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+ import ImmutablePureComponent from 'react-immutable-pure-component';
+
+ // Mastodon imports //
+ import emojify from '../../../mastodon/features/emoji/emoji';
+ import IconButton from '../../../mastodon/components/icon_button';
+ import Avatar from '../../../mastodon/components/avatar';
++import { me } from '../../../mastodon/initial_state';
+
+ // Our imports //
+ import { processBio } from '../../util/bio_metadata';
+
+ // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+ /*
+
+ Inital setup:
+ -------------
+
+ The `messages` constant is used to define any messages that we need
+ from inside props. In our case, these are the `unfollow`, `follow`, and
+ `requested` messages used in the `title` of our buttons.
+
+ */
+
+ const messages = defineMessages({
+ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+ follow: { id: 'account.follow', defaultMessage: 'Follow' },
+ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+ });
+
+ // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+ /*
+
+ Implementation:
+ ---------------
+
+ */
+
+ @injectIntl
+ export default class AccountHeader extends ImmutablePureComponent {
+
+ static propTypes = {
+ account : ImmutablePropTypes.map,
- const { account, me, intl } = this.props;
+ onFollow : PropTypes.func.isRequired,
+ intl : PropTypes.object.isRequired,
+ };
+
+ /*
+
+ ### `render()`
+
+ The `render()` function is used to render our component.
+
+ */
+
+ render () {
++ const { account, intl } = this.props;
+
+ /*
+
+ If no `account` is provided, then we can't render a header. Otherwise,
+ we get the `displayName` for the account, if available. If it's blank,
+ then we set the `displayName` to just be the `username` of the account.
+
+ */
+
+ if (!account) {
+ return null;
+ }
+
+ let displayName = account.get('display_name_html');
+ let info = '';
+ let actionBtn = '';
+ let following = false;
+
+ /*
+
+ Next, we handle the account relationships. If the account follows the
+ user, then we add an `info` message. If the user has requested a
+ follow, then we disable the `actionBtn` and display an hourglass.
+ Otherwise, if the account isn't blocked, we set the `actionBtn` to the
+ appropriate icon.
+
+ */
+
+ if (me !== account.get('id')) {
+ if (account.getIn(['relationship', 'followed_by'])) {
+ info = (
+ <span className='account--follows-info'>
+ <FormattedMessage id='account.follows_you' defaultMessage='Follows you' />
+ </span>
+ );
+ }
+ if (account.getIn(['relationship', 'requested'])) {
+ actionBtn = (
+ <div className='account--action-button'>
+ <IconButton size={26} disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />
+ </div>
+ );
+ } else if (!account.getIn(['relationship', 'blocking'])) {
+ following = account.getIn(['relationship', 'following']);
+ actionBtn = (
+ <div className='account--action-button'>
+ <IconButton
+ size={26}
+ icon={following ? 'user-times' : 'user-plus'}
+ active={following ? true : false}
+ title={intl.formatMessage(following ? messages.unfollow : messages.follow)}
+ onClick={this.props.onFollow}
+ />
+ </div>
+ );
+ }
+ }
+
+ /*
+ we extract the `text` and
+ `metadata` from our account's `note` using `processBio()`.
+
+ */
+
+ const { text, metadata } = processBio(account.get('note'));
+
+ /*
+
+ Here, we render our component using all the things we've defined above.
+
+ */
+
+ return (
+ <div className='account__header__wrapper'>
+ <div
+ className='account__header'
+ style={{ backgroundImage: `url(${account.get('header')})` }}
+ >
+ <div>
+ <a href={account.get('url')} target='_blank' rel='noopener'>
+ <span className='account__header__avatar'>
+ <Avatar account={account} size={90} />
+ </span>
+ <span
+ className='account__header__display-name'
+ dangerouslySetInnerHTML={{ __html: displayName }}
+ />
+ </a>
+ <span className='account__header__username'>
+ @{account.get('acct')}
+ {account.get('locked') ? <i className='fa fa-lock' /> : null}
+ </span>
+ <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
+
+ {info}
+ {actionBtn}
+ </div>
+ </div>
+
+ {metadata.length && (
+ <table className='account__metadata'>
+ <tbody>
+ {(() => {
+ let data = [];
+ for (let i = 0; i < metadata.length; i++) {
+ data.push(
+ <tr key={i}>
+ <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
+ <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
+ </tr>
+ );
+ }
+ return data;
+ })()}
+ </tbody>
+ </table>
+ ) || null}
+ </div>
+ );
+ }
+
+ }
--- /dev/null
- me: PropTypes.string,
+ // Package imports //
+ import React from 'react';
+ import ImmutablePropTypes from 'react-immutable-proptypes';
+ import PropTypes from 'prop-types';
+ import { defineMessages, injectIntl } from 'react-intl';
+ import ImmutablePureComponent from 'react-immutable-pure-component';
+
+ // Mastodon imports //
+ import RelativeTimestamp from '../../../mastodon/components/relative_timestamp';
+ import IconButton from '../../../mastodon/components/icon_button';
+ import DropdownMenuContainer from '../../../mastodon/containers/dropdown_menu_container';
++import { me } from '../../../mastodon/initial_state';
+
+ const messages = defineMessages({
+ delete: { id: 'status.delete', defaultMessage: 'Delete' },
+ mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+ mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+ block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ share: { id: 'status.share', defaultMessage: 'Share' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ open: { id: 'status.open', defaultMessage: 'Expand this status' },
+ report: { id: 'status.report', defaultMessage: 'Report @{name}' },
+ muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+ unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+ unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+ embed: { id: 'status.embed', defaultMessage: 'Embed' },
+ });
+
+ @injectIntl
+ export default class StatusActionBar extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ status: ImmutablePropTypes.map.isRequired,
+ onReply: PropTypes.func,
+ onFavourite: PropTypes.func,
+ onReblog: PropTypes.func,
+ onDelete: PropTypes.func,
+ onMention: PropTypes.func,
+ onMute: PropTypes.func,
+ onBlock: PropTypes.func,
+ onReport: PropTypes.func,
+ onEmbed: PropTypes.func,
+ onMuteConversation: PropTypes.func,
+ onPin: PropTypes.func,
- 'me',
+ withDismiss: PropTypes.bool,
+ intl: PropTypes.object.isRequired,
+ };
+
+ // Avoid checking props that are functions (and whose equality will always
+ // evaluate to false. See react-immutable-pure-component for usage.
+ updateOnProps = [
+ 'status',
- const { status, me, intl, withDismiss } = this.props;
+ 'withDismiss',
+ ]
+
+ handleReplyClick = () => {
+ this.props.onReply(this.props.status, this.context.router.history);
+ }
+
+ handleShareClick = () => {
+ navigator.share({
+ text: this.props.status.get('search_index'),
+ url: this.props.status.get('url'),
+ });
+ }
+
+ handleFavouriteClick = () => {
+ this.props.onFavourite(this.props.status);
+ }
+
+ handleReblogClick = (e) => {
+ this.props.onReblog(this.props.status, e);
+ }
+
+ handleDeleteClick = () => {
+ this.props.onDelete(this.props.status);
+ }
+
+ handlePinClick = () => {
+ this.props.onPin(this.props.status);
+ }
+
+ handleMentionClick = () => {
+ this.props.onMention(this.props.status.get('account'), this.context.router.history);
+ }
+
+ handleMuteClick = () => {
+ this.props.onMute(this.props.status.get('account'));
+ }
+
+ handleBlockClick = () => {
+ this.props.onBlock(this.props.status.get('account'));
+ }
+
+ handleOpen = () => {
+ this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+ }
+
+ handleEmbed = () => {
+ this.props.onEmbed(this.props.status);
+ }
+
+ handleReport = () => {
+ this.props.onReport(this.props.status);
+ }
+
+ handleConversationMuteClick = () => {
+ this.props.onMuteConversation(this.props.status);
+ }
+
+ render () {
++ const { status, intl, withDismiss } = this.props;
+
+ const mutingConversation = status.get('muted');
+ const anonymousAccess = !me;
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+ let menu = [];
+ let reblogIcon = 'retweet';
+ let replyIcon;
+ let replyTitle;
+
+ menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
+
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+ }
+
+ menu.push(null);
+
+ if (status.getIn(['account', 'id']) === me || withDismiss) {
+ menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+ menu.push(null);
+ }
+
+ if (status.getIn(['account', 'id']) === me) {
+ if (publicStatus) {
+ menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+ }
+
+ menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+ menu.push(null);
+ menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+ menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+ menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+ }
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+ <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
+ );
+
+ return (
+ <div className='status__action-bar'>
+ <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
+ <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
+ <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+ {shareButton}
+
+ <div className='status__action-bar-dropdown'>
+ <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+ </div>
+
+ <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+ </div>
+ );
+ }
+
+ }
--- /dev/null
- me : state.getIn(['meta', 'me']),
+ /*
+
+ `<StatusContainer>`
+ ===================
+
+ Original file by @gargron@mastodon.social et al as part of
+ tootsuite/mastodon. Documentation by @kibi@glitch.social. The code
+ detecting reblogs has been moved here from <Status>.
+
+ */
+
+ /* * * * */
+
+ /*
+
+ Imports:
+ --------
+
+ */
+
+ // Package imports //
+ import React from 'react';
+ import { connect } from 'react-redux';
+ import {
+ defineMessages,
+ injectIntl,
+ FormattedMessage,
+ } from 'react-intl';
+
+ // Mastodon imports //
+ import { makeGetStatus } from '../../../mastodon/selectors';
+ import {
+ replyCompose,
+ mentionCompose,
+ } from '../../../mastodon/actions/compose';
+ import {
+ reblog,
+ favourite,
+ unreblog,
+ unfavourite,
+ pin,
+ unpin,
+ } from '../../../mastodon/actions/interactions';
+ import { blockAccount } from '../../../mastodon/actions/accounts';
+ import { initMuteModal } from '../../../mastodon/actions/mutes';
+ import {
+ muteStatus,
+ unmuteStatus,
+ deleteStatus,
+ } from '../../../mastodon/actions/statuses';
+ import { initReport } from '../../../mastodon/actions/reports';
+ import { openModal } from '../../../mastodon/actions/modal';
+
+ // Our imports //
+ import Status from '.';
+
+ /* * * * */
+
+ /*
+
+ Inital setup:
+ -------------
+
+ The `messages` constant is used to define any messages that we will
+ need in our component. In our case, these are the various confirmation
+ messages used with statuses.
+
+ */
+
+ 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?',
+ },
+ blockConfirm : {
+ id : 'confirmations.block.confirm',
+ defaultMessage : 'Block',
+ },
+ });
+
+ /* * * * */
+
+ /*
+
+ State mapping:
+ --------------
+
+ The `mapStateToProps()` function maps various state properties to the
+ props of our component. We wrap this in a `makeMapStateToProps()`
+ function to give us closure and preserve `getStatus()` across function
+ calls.
+
+ */
+
+ const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, ownProps) => {
+
+ let status = getStatus(state, ownProps.id);
+
+ if(status === null) {
+ console.error(`ERROR! NULL STATUS! ${ownProps.id}`);
+ // work-around: find first good status
+ for (let k of state.get('statuses').keys()) {
+ status = getStatus(state, k);
+ if (status !== null) break;
+ }
+ }
+
+ let reblogStatus = status.get('reblog', null);
+ let account = undefined;
+ let prepend = undefined;
+
+ /*
+
+ Here we process reblogs. If our status is a reblog, then we create a
+ `prependMessage` to pass along to our `<Status>` along with the
+ reblogger's `account`, and set `coreStatus` (the one we will actually
+ render) to the status which has been reblogged.
+
+ */
+
+ if (reblogStatus !== null && typeof reblogStatus === 'object') {
+ account = status.get('account');
+ status = reblogStatus;
+ prepend = 'reblogged_by';
+ }
+
+ /*
+
+ Here are the props we pass to `<Status>`.
+
+ */
+
+ return {
+ status : status,
+ account : account || ownProps.account,
- autoPlayGif : state.getIn(['meta', 'auto_play_gif']),
+ settings : state.get('local_settings'),
+ prepend : prepend || ownProps.prepend,
+ reblogModal : state.getIn(['meta', 'boost_modal']),
+ deleteModal : state.getIn(['meta', 'delete_modal']),
+ };
+ };
+
+ return mapStateToProps;
+ };
+
+ /* * * * */
+
+ /*
+
+ Dispatch mapping:
+ -----------------
+
+ The `mapDispatchToProps()` function maps dispatches to our store to the
+ various props of our component. We need to provide dispatches for all
+ of the things you can do with a status: reply, reblog, favourite, et
+ cetera.
+
+ For a few of these dispatches, we open up confirmation modals; the rest
+ just immediately execute their corresponding actions.
+
+ */
+
+ const mapDispatchToProps = (dispatch, { intl }) => ({
+
+ onReply (status, router) {
+ dispatch(replyCompose(status, router));
+ },
+
+ onModalReblog (status) {
+ dispatch(reblog(status));
+ },
+
+ onReblog (status, e) {
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else {
+ if (e.shiftKey || !this.reblogModal) {
+ 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') }));
+ },
+
+ onDelete (status) {
+ if (!this.deleteModal) {
+ dispatch(deleteStatus(status.get('id')));
+ } else {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.deleteMessage),
+ confirm: intl.formatMessage(messages.deleteConfirm),
+ onConfirm: () => dispatch(deleteStatus(status.get('id'))),
+ }));
+ }
+ },
+
+ 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')));
+ }
+ },
+ });
+
+ export default injectIntl(
+ connect(makeMapStateToProps, mapDispatchToProps)(Status)
+ );
--- /dev/null
- - __`me` (`PropTypes.number`) :__
- This is the id of the currently-signed-in user.
-
+ /*
+
+ `<Status>`
+ ==========
+
+ Original file by @gargron@mastodon.social et al as part of
+ tootsuite/mastodon. *Heavily* rewritten (and documented!) by
+ @kibi@glitch.social as a part of glitch-soc/mastodon. The following
+ features have been added:
+
+ - Better separating the "guts" of statuses from their wrapper(s)
+ - Collapsing statuses
+ - Moving images inside of CWs
+
+ A number of aspects of this original file have been split off into
+ their own components for better maintainance; for these, see:
+
+ - <StatusHeader>
+ - <StatusPrepend>
+
+ …And, of course, the other <Status>-related components as well.
+
+ */
+
+ /* * * * */
+
+ /*
+
+ Imports:
+ --------
+
+ */
+
+ // Package imports //
+ import React from 'react';
+ import PropTypes from 'prop-types';
+ import ImmutablePropTypes from 'react-immutable-proptypes';
+ import ImmutablePureComponent from 'react-immutable-pure-component';
+
+ // Mastodon imports //
+ import scheduleIdleTask from '../../../mastodon/features/ui/util/schedule_idle_task';
++import { autoPlayGif } from '../../../mastodon/initial_state';
+
+ // Our imports //
+ import StatusPrepend from './prepend';
+ import StatusHeader from './header';
+ import StatusContent from './content';
+ import StatusActionBar from './action_bar';
+ import StatusGallery from './gallery';
+ import StatusPlayer from './player';
+ import NotificationOverlayContainer from '../notification/overlay/container';
+
+ /* * * * */
+
+ /*
+
+ The `<Status>` component:
+ -------------------------
+
+ The `<Status>` component is a container for statuses. It consists of a
+ few parts:
+
+ - The `<StatusPrepend>`, which contains tangential information about
+ the status, such as who reblogged it.
+ - The `<StatusHeader>`, which contains the avatar and username of the
+ status author, as well as a media icon and the "collapse" toggle.
+ - The `<StatusContent>`, which contains the content of the status.
+ - The `<StatusActionBar>`, which provides actions to be performed
+ on statuses, like reblogging or sending a reply.
+
+ ### Context
+
+ - __`router` (`PropTypes.object`) :__
+ We need to get our router from the surrounding React context.
+
+ ### Props
+
+ - __`id` (`PropTypes.number`) :__
+ The id of the status.
+
+ - __`status` (`ImmutablePropTypes.map`) :__
+ The status object, straight from the store.
+
+ - __`account` (`ImmutablePropTypes.map`) :__
+ Don't be confused by this one! This is **not** the account which
+ posted the status, but the associated account with any further
+ action (eg, a reblog or a favourite).
+
+ - __`settings` (`ImmutablePropTypes.map`) :__
+ These are our local settings, fetched from our store. We need this
+ to determine how best to collapse our statuses, among other things.
+
- - __`autoPlayGif` (`PropTypes.bool`) :__
- This tells the frontend whether or not to autoplay gifs!
-
+ - __`onFavourite`, `onReblog`, `onModalReblog`, `onDelete`,
+ `onMention`, `onMute`, `onMuteConversation`, onBlock`, `onReport`,
+ `onOpenMedia`, `onOpenVideo` (`PropTypes.func`) :__
+ These are all functions passed through from the
+ `<StatusContainer>`. We don't deal with them directly here.
+
+ - __`reblogModal`, `deleteModal` (`PropTypes.bool`) :__
+ These tell whether or not the user has modals activated for
+ reblogging and deleting statuses. They are used by the `onReblog`
+ and `onDelete` functions, but we don't deal with them here.
+
- me : PropTypes.string,
+ - __`muted` (`PropTypes.bool`) :__
+ This has nothing to do with a user or conversation mute! "Muted" is
+ what Mastodon internally calls the subdued look of statuses in the
+ notifications column. This should be `true` for notifications, and
+ `false` otherwise.
+
+ - __`collapse` (`PropTypes.bool`) :__
+ This prop signals a directive from a higher power to (un)collapse
+ a status. Most of the time it should be `undefined`, in which case
+ we do nothing.
+
+ - __`prepend` (`PropTypes.string`) :__
+ The type of prepend: `'reblogged_by'`, `'reblog'`, or
+ `'favourite'`.
+
+ - __`withDismiss` (`PropTypes.bool`) :__
+ Whether or not the status can be dismissed. Used for notifications.
+
+ - __`intersectionObserverWrapper` (`PropTypes.object`) :__
+ This holds our intersection observer. In Mastodon parlance,
+ an "intersection" is just when the status is viewable onscreen.
+
+ ### State
+
+ - __`isExpanded` :__
+ Should be either `true`, `false`, or `null`. The meanings of
+ these values are as follows:
+
+ - __`true` :__ The status contains a CW and the CW is expanded.
+ - __`false` :__ The status is collapsed.
+ - __`null` :__ The status is not collapsed or expanded.
+
+ - __`isIntersecting` :__
+ This boolean tells us whether or not the status is currently
+ onscreen.
+
+ - __`isHidden` :__
+ This boolean tells us if the status has been unrendered to save
+ CPUs.
+
+ */
+
+ export default class Status extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router : PropTypes.object,
+ };
+
+ static propTypes = {
+ id : PropTypes.string,
+ status : ImmutablePropTypes.map,
+ account : ImmutablePropTypes.map,
+ settings : ImmutablePropTypes.map,
+ notification : ImmutablePropTypes.map,
- autoPlayGif : PropTypes.bool,
+ onFavourite : PropTypes.func,
+ onReblog : PropTypes.func,
+ onModalReblog : PropTypes.func,
+ onDelete : PropTypes.func,
+ onPin : PropTypes.func,
+ onMention : PropTypes.func,
+ onMute : PropTypes.func,
+ onMuteConversation : PropTypes.func,
+ onBlock : PropTypes.func,
+ onEmbed : PropTypes.func,
+ onHeightChange : PropTypes.func,
+ onReport : PropTypes.func,
+ onOpenMedia : PropTypes.func,
+ onOpenVideo : PropTypes.func,
+ reblogModal : PropTypes.bool,
+ deleteModal : PropTypes.bool,
- 'me',
+ muted : PropTypes.bool,
+ collapse : PropTypes.bool,
+ prepend : PropTypes.string,
+ withDismiss : PropTypes.bool,
+ intersectionObserverWrapper : PropTypes.object,
+ };
+
+ state = {
+ isExpanded : null,
+ isIntersecting : true,
+ isHidden : false,
+ markedForDelete : false,
+ }
+
+ /*
+
+ ### Implementation
+
+ #### `updateOnProps` and `updateOnStates`.
+
+ `updateOnProps` and `updateOnStates` tell the component when to update.
+ We specify them explicitly because some of our props are dynamically=
+ generated functions, which would otherwise always trigger an update.
+ Of course, this means that if we add an important prop, we will need
+ to remember to specify it here.
+
+ */
+
+ updateOnProps = [
+ 'status',
+ 'account',
+ 'settings',
+ 'prepend',
- 'autoPlayGif',
+ 'boostModal',
- autoPlayGif,
+ 'muted',
+ 'collapse',
+ 'notification',
+ ]
+
+ updateOnStates = [
+ 'isExpanded',
+ 'markedForDelete',
+ ]
+
+ /*
+
+ #### `componentWillReceiveProps()`.
+
+ If our settings have changed to disable collapsed statuses, then we
+ need to make sure that we uncollapse every one. We do that by watching
+ for changes to `settings.collapsed.enabled` in
+ `componentWillReceiveProps()`.
+
+ We also need to watch for changes on the `collapse` prop---if this
+ changes to anything other than `undefined`, then we need to collapse or
+ uncollapse our status accordingly.
+
+ */
+
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
+ if (this.state.isExpanded === false) {
+ this.setExpansion(null);
+ }
+ } else if (
+ nextProps.collapse !== this.props.collapse &&
+ nextProps.collapse !== undefined
+ ) this.setExpansion(nextProps.collapse ? false : null);
+ }
+
+ /*
+
+ #### `componentDidMount()`.
+
+ When mounting, we just check to see if our status should be collapsed,
+ and collapse it if so. We don't need to worry about whether collapsing
+ is enabled here, because `setExpansion()` already takes that into
+ account.
+
+ The cases where a status should be collapsed are:
+
+ - The `collapse` prop has been set to `true`
+ - The user has decided in local settings to collapse all statuses.
+ - The user has decided to collapse all notifications ('muted'
+ statuses).
+ - The user has decided to collapse long statuses and the status is
+ over 400px (without media, or 650px with).
+ - The status is a reply and the user has decided to collapse all
+ replies.
+ - The status contains media and the user has decided to collapse all
+ statuses with media.
+
+ We also start up our intersection observer to monitor our statuses.
+ `componentMounted` lets us know that everything has been set up
+ properly and our intersection observer is good to go.
+
+ */
+
+ componentDidMount () {
+ const { node, handleIntersection } = this;
+ const {
+ status,
+ settings,
+ collapse,
+ muted,
+ id,
+ intersectionObserverWrapper,
+ prepend,
+ } = this.props;
+ const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
+
+ if (
+ collapse ||
+ autoCollapseSettings.get('all') || (
+ autoCollapseSettings.get('notifications') && muted
+ ) || (
+ autoCollapseSettings.get('lengthy') &&
+ node.clientHeight > (
+ status.get('media_attachments').size && !muted ? 650 : 400
+ )
+ ) || (
+ autoCollapseSettings.get('reblogs') &&
+ prepend === 'reblogged_by'
+ ) || (
+ autoCollapseSettings.get('replies') &&
+ status.get('in_reply_to_id', null) !== null
+ ) || (
+ autoCollapseSettings.get('media') &&
+ !(status.get('spoiler_text').length) &&
+ status.get('media_attachments').size
+ )
+ ) this.setExpansion(false);
+
+ if (!intersectionObserverWrapper) return;
+ else intersectionObserverWrapper.observe(
+ id,
+ node,
+ handleIntersection
+ );
+
+ this.componentMounted = true;
+ }
+
+ /*
+
+ #### `shouldComponentUpdate()`.
+
+ If the status is about to be both offscreen (not intersecting) and
+ hidden, then we only need to update it if it's not that way currently.
+ If the status is moving from offscreen to onscreen, then we *have* to
+ re-render, so that we can unhide the element if necessary.
+
+ If neither of these cases are true, we can leave it up to our
+ `updateOnProps` and `updateOnStates` arrays.
+
+ */
+
+ shouldComponentUpdate (nextProps, nextState) {
+ switch (true) {
+ case !nextState.isIntersecting && nextState.isHidden:
+ return this.state.isIntersecting || !this.state.isHidden;
+ case nextState.isIntersecting && !this.state.isIntersecting:
+ return true;
+ default:
+ return super.shouldComponentUpdate(nextProps, nextState);
+ }
+ }
+
+ /*
+
+ #### `componentDidUpdate()`.
+
+ If our component is being rendered for any reason and an update has
+ triggered, this will save its height.
+
+ This is, frankly, a bit overkill, as the only instance when we
+ actually *need* to update the height right now should be when the
+ value of `isExpanded` has changed. But it makes for more readable
+ code and prevents bugs in the future where the height isn't set
+ properly after some change.
+
+ */
+
+ componentDidUpdate () {
+ if (
+ this.state.isIntersecting || !this.state.isHidden
+ ) this.saveHeight();
+ }
+
+ /*
+
+ #### `componentWillUnmount()`.
+
+ If our component is about to unmount, then we'd better unset
+ `this.componentMounted`.
+
+ */
+
+ componentWillUnmount () {
+ this.componentMounted = false;
+ }
+
+ /*
+
+ #### `handleIntersection()`.
+
+ `handleIntersection()` either hides the status (if it is offscreen) or
+ unhides it (if it is onscreen). It's called by
+ `intersectionObserverWrapper.observe()`.
+
+ If our status isn't intersecting, we schedule an idle task (using the
+ aptly-named `scheduleIdleTask()`) to hide the status at the next
+ available opportunity.
+
+ tootsuite/mastodon left us with the following enlightening comment
+ regarding this function:
+
+ > Edge 15 doesn't support isIntersecting, but we can infer it
+
+ It then implements a polyfill (intersectionRect.height > 0) which isn't
+ actually sufficient. The short answer is, this behaviour isn't really
+ supported on Edge but we can get kinda close.
+
+ */
+
+ handleIntersection = (entry) => {
+ const isIntersecting = (
+ typeof entry.isIntersecting === 'boolean' ?
+ entry.isIntersecting :
+ entry.intersectionRect.height > 0
+ );
+ this.setState(
+ (prevState) => {
+ if (prevState.isIntersecting && !isIntersecting) {
+ scheduleIdleTask(this.hideIfNotIntersecting);
+ }
+ return {
+ isIntersecting : isIntersecting,
+ isHidden : false,
+ };
+ }
+ );
+ }
+
+ /*
+
+ #### `hideIfNotIntersecting()`.
+
+ This function will hide the status if we're still not intersecting.
+ Hiding the status means that it will just render an empty div instead
+ of actual content, which saves RAMS and CPUs or some such.
+
+ */
+
+ hideIfNotIntersecting = () => {
+ if (!this.componentMounted) return;
+ this.setState(
+ (prevState) => ({ isHidden: !prevState.isIntersecting })
+ );
+ }
+
+ /*
+
+ #### `saveHeight()`.
+
+ `saveHeight()` saves the height of our status so that when whe hide it
+ we preserve its dimensions. We only want to store our height, though,
+ if our status has content (otherwise, it would imply that it is
+ already hidden).
+
+ */
+
+ saveHeight = () => {
+ if (this.node && this.node.children.length) {
+ this.height = this.node.getBoundingClientRect().height;
+ }
+ }
+
+ /*
+
+ #### `setExpansion()`.
+
+ `setExpansion()` sets the value of `isExpanded` in our state. It takes
+ one argument, `value`, which gives the desired value for `isExpanded`.
+ The default for this argument is `null`.
+
+ `setExpansion()` automatically checks for us whether toot collapsing
+ is enabled, so we don't have to.
+
+ We use a `switch` statement to simplify our code.
+
+ */
+
+ setExpansion = (value) => {
+ switch (true) {
+ case value === undefined || value === null:
+ this.setState({ isExpanded: null });
+ break;
+ case !value && this.props.settings.getIn(['collapsed', 'enabled']):
+ this.setState({ isExpanded: false });
+ break;
+ case !!value:
+ this.setState({ isExpanded: true });
+ break;
+ }
+ }
+
+ /*
+
+ #### `handleRef()`.
+
+ `handleRef()` just saves a reference to our status node to `this.node`.
+ It also saves our height, in case the height of our node has changed.
+
+ */
+
+ handleRef = (node) => {
+ this.node = node;
+ this.saveHeight();
+ }
+
+ /*
+
+ #### `parseClick()`.
+
+ `parseClick()` takes a click event and responds appropriately.
+ If our status is collapsed, then clicking on it should uncollapse it.
+ If `Shift` is held, then clicking on it should collapse it.
+ Otherwise, we open the url handed to us in `destination`, if
+ applicable.
+
+ */
+
+ parseClick = (e, destination) => {
+ const { router } = this.context;
+ const { status } = this.props;
+ const { isExpanded } = this.state;
+ if (!router) return;
+ if (destination === undefined) {
+ destination = `/statuses/${
+ status.getIn(['reblog', 'id'], status.get('id'))
+ }`;
+ }
+ if (e.button === 0) {
+ if (isExpanded === false) this.setExpansion(null);
+ else if (e.shiftKey) {
+ this.setExpansion(false);
+ document.getSelection().removeAllRanges();
+ } else router.history.push(destination);
+ e.preventDefault();
+ }
+ }
+
+ /*
+
+ #### `render()`.
+
+ `render()` actually puts our element on the screen. The particulars of
+ this operation are further explained in the code below.
+
+ */
+
+ render () {
+ const {
+ parseClick,
+ setExpansion,
+ saveHeight,
+ handleRef,
+ } = this;
+ const { router } = this.context;
+ const {
+ status,
+ account,
+ settings,
+ collapsed,
+ muted,
+ prepend,
+ intersectionObserverWrapper,
+ onOpenVideo,
+ onOpenMedia,
+ notification,
+ ...other
+ } = this.props;
+ const { isExpanded, isIntersecting, isHidden } = this.state;
+ let background = null;
+ let attachments = null;
+ let media = null;
+ let mediaIcon = null;
+
+ /*
+
+ If we don't have a status, then we don't render anything.
+
+ */
+
+ if (status === null) {
+ return null;
+ }
+
+ /*
+
+ If our status is offscreen and hidden, then we render an empty <div> in
+ its place. We fill it with "content" but note that opacity is set to 0.
+
+ */
+
+ if (!isIntersecting && isHidden) {
+ return (
+ <div
+ ref={this.handleRef}
+ data-id={status.get('id')}
+ style={{
+ height : `${this.height}px`,
+ opacity : 0,
+ overflow : 'hidden',
+ }}
+ >
+ {
+ status.getIn(['account', 'display_name']) ||
+ status.getIn(['account', 'username'])
+ }
+ {status.get('content')}
+ </div>
+ );
+ }
+
+ /*
+
+ If user backgrounds for collapsed statuses are enabled, then we
+ initialize our background accordingly. This will only be rendered if
+ the status is collapsed.
+
+ */
+
+ if (
+ settings.getIn(['collapsed', 'backgrounds', 'user_backgrounds'])
+ ) background = status.getIn(['account', 'header']);
+
+ /*
+
+ This handles our media attachments. Note that we don't show media on
+ muted (notification) statuses. If the media type is unknown, then we
+ simply ignore it.
+
+ After we have generated our appropriate media element and stored it in
+ `media`, we snatch the thumbnail to use as our `background` if media
+ backgrounds for collapsed statuses are enabled.
+
+ */
+
+ attachments = status.get('media_attachments');
+ if (attachments.size && !muted) {
+ if (attachments.some((item) => item.get('type') === 'unknown')) {
+
+ } else if (
+ attachments.getIn([0, 'type']) === 'video'
+ ) {
+ media = ( // Media type is 'video'
+ <StatusPlayer
+ media={attachments.get(0)}
+ sensitive={status.get('sensitive')}
+ letterbox={settings.getIn(['media', 'letterbox'])}
+ fullwidth={settings.getIn(['media', 'fullwidth'])}
+ height={250}
+ onOpenVideo={onOpenVideo}
+ />
+ );
+ mediaIcon = 'video-camera';
+ } else { // Media type is 'image' or 'gifv'
+ media = (
+ <StatusGallery
+ media={attachments}
+ sensitive={status.get('sensitive')}
+ letterbox={settings.getIn(['media', 'letterbox'])}
+ fullwidth={settings.getIn(['media', 'fullwidth'])}
+ height={250}
+ onOpenMedia={onOpenMedia}
+ autoPlayGif={autoPlayGif}
+ />
+ );
+ mediaIcon = 'picture-o';
+ }
+
+ if (
+ !status.get('sensitive') &&
+ !(status.get('spoiler_text').length > 0) &&
+ settings.getIn(['collapsed', 'backgrounds', 'preview_images'])
+ ) background = attachments.getIn([0, 'preview_url']);
+ }
+
+ /*
+
+ Here we prepare extra data-* attributes for CSS selectors.
+ Users can use those for theming, hiding avatars etc via UserStyle
+
+ */
+
+ const selectorAttribs = {
+ 'data-status-by': `@${status.getIn(['account', 'acct'])}`,
+ };
+
+ if (prepend && account) {
+ const notifKind = {
+ favourite: 'favourited',
+ reblog: 'boosted',
+ reblogged_by: 'boosted',
+ }[prepend];
+
+ selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;
+ }
+
+ /*
+
+ Finally, we can render our status. We just put the pieces together
+ from above. We only render the action bar if the status isn't
+ collapsed.
+
+ */
+
+ return (
+ <article
+ className={
+ `status${
+ muted ? ' muted' : ''
+ } status-${status.get('visibility')}${
+ isExpanded === false ? ' collapsed' : ''
+ }${
+ isExpanded === false && background ? ' has-background' : ''
+ }${
+ this.state.markedForDelete ? ' marked-for-delete' : ''
+ }`
+ }
+ style={{
+ backgroundImage: (
+ isExpanded === false && background ?
+ `url(${background})` :
+ 'none'
+ ),
+ }}
+ ref={handleRef}
+ {...selectorAttribs}
+ >
+ {prepend && account ? (
+ <StatusPrepend
+ type={prepend}
+ account={account}
+ parseClick={parseClick}
+ notificationId={this.props.notificationId}
+ />
+ ) : null}
+ <StatusHeader
+ status={status}
+ friend={account}
+ mediaIcon={mediaIcon}
+ collapsible={settings.getIn(['collapsed', 'enabled'])}
+ collapsed={isExpanded === false}
+ parseClick={parseClick}
+ setExpansion={setExpansion}
+ />
+ <StatusContent
+ status={status}
+ media={media}
+ mediaIcon={mediaIcon}
+ expanded={isExpanded}
+ setExpansion={setExpansion}
+ onHeightUpdate={saveHeight}
+ parseClick={parseClick}
+ disabled={!router}
+ />
+ {isExpanded !== false ? (
+ <StatusActionBar
+ {...other}
+ status={status}
+ account={status.get('account')}
+ />
+ ) : null}
+ {notification ? (
+ <NotificationOverlayContainer
+ notification={notification}
+ />
+ ) : null}
+ </article>
+ );
+
+ }
+
+ }
expanded,
icon,
inverted,
++ flip,
overlay,
pressed,
tabIndex,
overlayed: overlay,
});
- const flipDeg = this.props.flip ? -180 : -360;
- const rotateDeg = this.props.active ? flipDeg : 0;
++ const flipDeg = flip ? -180 : -360;
++ const rotateDeg = active ? flipDeg : 0;
+
+ const motionDefaultStyle = {
+ rotate: rotateDeg,
+ };
+
+ const springOpts = {
+ stiffness: this.props.flip ? 60 : 120,
+ damping: 7,
+ };
+ const motionStyle = {
+ rotate: animate ? spring(rotateDeg, springOpts) : 0,
+ };
+
+ if (!animate) {
+ // Perf optimization: avoid unnecessary <Motion> components unless
+ // we actually need to animate.
+ return (
+ <button
+ aria-label={title}
+ aria-pressed={pressed}
+ aria-expanded={expanded}
+ title={title}
+ className={classes}
+ onClick={this.handleClick}
+ style={style}
+ tabIndex={tabIndex}
+ >
+ <i className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
+ </button>
+ );
+ }
+
return (
- <Motion defaultStyle={{ rotate: active ? -360 : 0 }} style={{ rotate: animate ? spring(active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}>
+ <Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
{({ rotate }) =>
<button
aria-label={title}
<ActionBar
account={account}
- me={me}
onBlock={this.handleBlock}
onMention={this.handleMention}
+ onReblogToggle={this.handleReblogToggle}
onReport={this.handleReport}
onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain}
preselectDate: state.getIn(['compose', 'preselectDate']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
- me: state.getIn(['compose', 'me']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+ settings: state.get('local_settings'),
+ filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
});
const mapDispatchToProps = (dispatch) => ({
static propTypes = {
intl: PropTypes.object.isRequired,
- me: ImmutablePropTypes.map.isRequired,
+ myAccount: ImmutablePropTypes.map.isRequired,
columns: ImmutablePropTypes.list,
multiColumn: PropTypes.bool,
+ dispatch: PropTypes.func.isRequired,
};
+ openSettings = () => {
+ this.props.dispatch(openModal('SETTINGS', {}));
+ }
+
+ openOnboardingModal = (e) => {
+ e.preventDefault();
+ this.props.dispatch(openModal('ONBOARDING'));
+ }
+
render () {
- const { intl, me, columns, multiColumn } = this.props;
+ const { intl, myAccount, columns, multiColumn } = this.props;
let navItems = [];
}
}
+ if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+ navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
+ }
+
navItems = navItems.concat([
- <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
- <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
+ <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
+ <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
]);
- if (me.get('locked')) {
+ if (myAccount.get('locked')) {
- navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+ navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
}
navItems = navItems.concat([
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
+ settings: ImmutablePropTypes.map.isRequired,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
- autoPlayGif: PropTypes.bool,
};
handleAccountClick = (e) => {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />;
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
- const video = status.getIn(['media_attachments', 0]);
-
media = (
- <Video
- preview={video.get('preview_url')}
- src={video.get('url')}
- width={300}
- height={150}
- onOpenVideo={this.handleOpenVideo}
+ <StatusPlayer
sensitive={status.get('sensitive')}
+ media={status.getIn(['media_attachments', 0])}
+ letterbox={settings.getIn(['media', 'letterbox'])}
+ fullwidth={settings.getIn(['media', 'fullwidth'])}
+ height={250}
+ onOpenVideo={this.props.onOpenVideo}
+ autoplay
/>
);
+ mediaIcon = 'video-camera';
} else {
media = (
- <MediaGallery
- standalone
+ <StatusGallery
sensitive={status.get('sensitive')}
media={status.get('media_attachments')}
- height={300}
+ letterbox={settings.getIn(['media', 'letterbox'])}
+ fullwidth={settings.getIn(['media', 'fullwidth'])}
+ height={250}
onOpenMedia={this.props.onOpenMedia}
- autoPlayGif={this.props.autoPlayGif}
/>
);
+ mediaIcon = 'picture-o';
}
- } else if (status.get('spoiler_text').length === 0) {
- media = <CardContainer statusId={status.get('id')} />;
- }
+ } else media = <CardContainer statusId={status.get('id')} />;
if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
import { deleteStatus } from '../../actions/statuses';
import { initReport } from '../../actions/reports';
import { makeGetStatus } from '../../selectors';
-import { ScrollContainer } from 'react-router-scroll';
+import { ScrollContainer } from 'react-router-scroll-4';
import ColumnBackButton from '../../components/column_back_button';
- import StatusContainer from '../../containers/status_container';
+ import StatusContainer from '../../../glitch/components/status/container';
import { openModal } from '../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.params.statusId),
+ settings: state.get('local_settings'),
ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
- me: state.getIn(['meta', 'me']),
- boostModal: state.getIn(['meta', 'boost_modal']),
- deleteModal: state.getIn(['meta', 'delete_modal']),
- autoPlayGif: state.getIn(['meta', 'auto_play_gif']),
});
return mapStateToProps;
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
status: ImmutablePropTypes.map,
+ settings: ImmutablePropTypes.map.isRequired,
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
- me: PropTypes.string,
- boostModal: PropTypes.bool,
- deleteModal: PropTypes.bool,
- autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
}
}
+ componentWillUnmount () {
+ detachFullscreenListener(this.onFullScreenChange);
+ }
+
+ onFullScreenChange = () => {
+ this.setState({ fullscreen: isFullscreen() });
+ }
+
render () {
let ancestors, descendants;
- const { status, ancestorsIds, descendantsIds } = this.props;
- const { status, settings, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
++ const { status, settings, ancestorsIds, descendantsIds } = this.props;
+ const { fullscreen } = this.state;
if (status === null) {
return (
<div className='focusable' tabIndex='0'>
<DetailedStatus
status={status}
- autoPlayGif={autoPlayGif}
- me={me}
+ settings={settings}
onOpenVideo={this.handleOpenVideo}
onOpenMedia={this.handleOpenMedia}
/>
import Search from '../../compose/components/search';
import NavigationBar from '../../compose/components/navigation_bar';
import ColumnHeader from './column_header';
- import { List as ImmutableList } from 'immutable';
+ import {
+ List as ImmutableList,
+ Map as ImmutableMap,
+ } from 'immutable';
+import { me } from '../../../initial_state';
const noop = () => { };
<div className='onboarding-modal__page onboarding-modal__page-two'>
<div className='figure non-interactive'>
<div className='pseudo-drawer'>
- <NavigationBar account={myAccount} />
- <NavigationBar onClose={noop} account={me} />
++ <NavigationBar onClose={noop} account={myAccount} />
</div>
<ComposeForm
text='Awoo! #introductions'
/>
<div className='pseudo-drawer'>
- <NavigationBar account={myAccount} />
- <NavigationBar onClose={noop} account={me} />
++ <NavigationBar onClose={noop} account={myAccount} />
</div>
</div>
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
- import '../../components/status';
+ import '../../../glitch/components/status';
+const messages = defineMessages({
+ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
+});
+
const mapStateToProps = state => ({
- systemFontUi: state.getIn(['meta', 'system_font_ui']),
- layout: state.getIn(['local_settings', 'layout']),
- isWide: state.getIn(['local_settings', 'stretch']),
- navbarUnder: state.getIn(['local_settings', 'navbar_under']),
- me: state.getIn(['meta', 'me']),
isComposing: state.getIn(['compose', 'is_composing']),
+ hasComposingText: state.getIn(['compose', 'text']) !== '',
});
const keyMap = {
static propTypes = {
dispatch: PropTypes.func.isRequired,
children: PropTypes.node,
+ layout: PropTypes.string,
+ isWide: PropTypes.bool,
+ systemFontUi: PropTypes.bool,
+ navbarUnder: PropTypes.bool,
isComposing: PropTypes.bool,
- me: PropTypes.string,
+ hasComposingText: PropTypes.bool,
location: PropTypes.object,
+ intl: PropTypes.object.isRequired,
};
state = {
--- /dev/null
- const initialState = element && JSON.parse(element.textContent);
+const element = document.getElementById('initial-state');
++const initialState = element && function () {
++ const result = JSON.parse(element.textContent);
++ try {
++ result.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
++ } catch (e) {
++ result.local_settings = {};
++ }
++ return result;
++}();
+
+const getMeta = (prop) => initialState && initialState.meta && initialState.meta[prop];
+
+export const reduceMotion = getMeta('reduce_motion');
+export const autoPlayGif = getMeta('auto_play_gif');
+export const unfollowModal = getMeta('unfollow_modal');
+export const boostModal = getMeta('boost_modal');
+export const deleteModal = getMeta('delete_modal');
+export const me = getMeta('me');
+
+export default initialState;
media_attachments: ImmutableList(),
suggestion_token: null,
suggestions: ImmutableList(),
- me: null,
+ default_advanced_options: ImmutableMap({
+ do_not_federate: false,
+ }),
default_privacy: 'public',
default_sensitive: false,
resetFileKey: Math.floor((Math.random() * 0x10000)),
cursor: pointer;
}
+ .status-check-box {
+ .status__content,
+ .reply-indicator__content {
+ color: #3a3a3a;
+ a {
+ color: #005aa9;
+ }
+ }
+ }
+
.status__content,
.reply-indicator__content {
+ position: relative;
+ margin: 10px 0;
+ padding: 0 12px;
font-size: 15px;
line-height: 20px;
+ color: $primary-text-color;
word-wrap: break-word;
font-weight: 400;
- overflow: hidden;
+ overflow: visible;
white-space: pre-wrap;
+ padding-top: 5px;
&.status__content--with-spoiler {
white-space: normal;
#
# created_at :datetime not null
# updated_at :datetime not null
- # account_id :bigint not null
- # id :bigint not null, primary key
- # target_account_id :bigint not null
-# account_id :integer not null
-# id :integer not null, primary key
-# target_account_id :integer not null
++# account_id :bigint not null
++# id :bigint not null, primary key
++# target_account_id :bigint not null
+ # show_reblogs :boolean default(TRUE), not null
#
class Follow < ApplicationRecord
#
# created_at :datetime not null
# updated_at :datetime not null
- # account_id :bigint not null
- # id :bigint not null, primary key
- # target_account_id :bigint not null
-# account_id :integer not null
-# id :integer not null, primary key
-# target_account_id :integer not null
++# account_id :bigint not null
++# id :bigint not null, primary key
++# target_account_id :bigint not null
+ # show_reblogs :boolean default(TRUE), not null
#
class FollowRequest < ApplicationRecord
#
# Table name: mutes
#
- # id :integer not null, primary key
++# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
--# account_id :integer not null
-# id :integer not null, primary key
--# target_account_id :integer not null
++# account_id :bigint not null
++# target_account_id :bigint not null
# hide_notifications :boolean default(TRUE), not null
#
end
def show?
+ return false if local_only? && account.nil?
+
if direct?
- owned? || status.mentions.where(account: account).exists?
+ owned? || record.mentions.where(account: current_account).exists?
elsif private?
- owned? || account&.following?(status.account) || status.mentions.where(account: account).exists?
+ owned? || current_account&.following?(author) || record.mentions.where(account: current_account).exists?
else
- account.nil? || !status.account.blocking?(account)
+ current_account.nil? || !author.blocking?(current_account)
end
end
end
def private?
- status.private_visibility?
+ record.private_visibility?
+ end
+
+ def author
+ record.account
end
- status.local_only?
+
+ def local_only?
++ record.local_only?
+ end
end
+ - processed_bio = FrontmatterHandler.instance.process_bio Formatter.instance.simplified_format account
.card.h-card.p-author{ style: "background-image: url(#{account.header.url(:original)})" }
.card__illustration
- - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
- .controls
- - if current_account.following?(account)
- = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do
- = fa_icon 'user-times'
- = t('accounts.unfollow')
- - else
- = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
- = fa_icon 'user-plus'
- = t('accounts.follow')
- - elsif !user_signed_in?
- .controls
- .remote-follow
- = link_to account_remote_follow_path(account), class: 'icon-button' do
- = fa_icon 'user-plus'
- = t('accounts.remote_follow')
+ - unless account.memorial?
+ - if user_signed_in? && current_account.id != account.id && !current_account.requested?(account)
+ .controls
+ - if current_account.following?(account)
+ = link_to account_unfollow_path(account), data: { method: :post }, class: 'icon-button' do
+ = fa_icon 'user-times'
+ = t('accounts.unfollow')
+ - else
+ = link_to account_follow_path(account), data: { method: :post }, class: 'icon-button' do
+ = fa_icon 'user-plus'
+ = t('accounts.follow')
+ - elsif !user_signed_in?
+ .controls
+ .remote-follow
+ = link_to account_remote_follow_path(account), class: 'icon-button' do
+ = fa_icon 'user-plus'
+ = t('accounts.remote_follow')
.avatar= image_tag account.avatar.url(:original), class: 'u-photo'
following: Following list
muting: Muting list
upload: Upload
+ in_memoriam_html: In Memoriam.
+ keyword_mutes:
+ add_keyword: Add keyword
+ edit: Edit
+ edit_keyword: Edit keyword
+ keyword: Keyword
+ match_whole_word: Match whole word
+ remove: Remove
+ remove_all: Remove all
landing_strip_html: "<strong>%{name}</strong> is a user on %{link_to_root_path}. You can follow them or interact with them if you have an account anywhere in the fediverse."
landing_strip_signup_html: If you don't, you can <a href="%{sign_up_path}">sign up here</a>.
media_attachments:
module.exports = {
entry: Object.assign(
- entryPacks.reduce(
- (map, entry) => {
- const localMap = map;
- let namespace = relative(join(entryPath), dirname(entry));
- if (namespace === join('..', '..', '..', 'tmp', 'packs')) {
- namespace = ''; // generated by generateLocalePacks.js
- }
- localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
- return localMap;
+ packPaths.reduce((map, entry) => {
+ const localMap = map;
+ const namespace = relative(join(entryPath), dirname(entry));
+ localMap[join(namespace, basename(entry, extname(entry)))] = resolve(entry);
+ return localMap;
+ }, {}),
+ localePackPaths.reduce((map, entry) => {
+ const localMap = map;
+ localMap[basename(entry, extname(entry, extname(entry)))] = resolve(entry);
+ return localMap;
+ }, {}),
- Object.keys(themes).reduce((themePaths, name) => {
- themePaths[name] = resolve(join(settings.source_path, themes[name]));
- return themePaths;
- }, {})
++ Object.keys(themes).reduce(
++ (themePaths, name) => {
++ const themeData = themes[name];
++ themePaths[`themes/${name}`] = resolve(themeData.pack_directory, themeData.pack);
++ return themePaths;
+ }, {}
- ), themePaths
++ )
),
output: {
resource.request = resource.request.replace(/^history/, 'history/es');
}
),
- new ExtractTextPlugin(env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css'),
+ new ExtractTextPlugin({
- filename: env.NODE_ENV === 'production' ? '[name]-[hash].css' : '[name].css',
++ filename: env.NODE_ENV === 'production' ? '[name]-[contenthash].css' : '[name].css',
+ allChunks: true,
+ }),
new ManifestPlugin({
publicPath: output.publicPath,
writeToFileEmit: true,
create_table "mutes", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.boolean "hide_notifications", default: true, null: false
t.bigint "account_id", null: false
t.bigint "target_account_id", null: false
+ t.boolean "hide_notifications", default: true, null: false
t.index ["account_id", "target_account_id"], name: "index_mutes_on_account_id_and_target_account_id", unique: true
end
"private": true,
"dependencies": {
"array-includes": "^3.0.3",
- "autoprefixer": "^7.1.2",
- "axios": "^0.16.2",
+ "atrament": "^0.2.3",
+ "autoprefixer": "^7.1.6",
+ "axios": "~0.16.2",
"babel-core": "^6.25.0",
"babel-loader": "^7.1.1",
"babel-plugin-lodash": "^3.2.11",
end
it 'does mute notifications' do
- expect(me.muting_notifications?(you)).to be true
- expect(@me.muting_notifications?(@you)).to be(true)
++ expect(me.muting_notifications?(you)).to be true
+ end
+ end
+ end
+
+ describe 'ignoring reblogs from an account' do
+ before do
+ @me = Fabricate(:account, username: 'Me')
+ @you = Fabricate(:account, username: 'You')
+ end
+
+ context 'with the reblogs option unspecified' do
+ before do
+ @me.follow!(@you)
+ end
+
+ it 'defaults to showing reblogs' do
+ expect(@me.muting_reblogs?(@you)).to be(false)
+ end
+ end
+
+ context 'with the reblogs option set to false' do
+ before do
+ @me.follow!(@you, reblogs: false)
+ end
+
+ it 'does mute reblogs' do
+ expect(@me.muting_reblogs?(@you)).to be(true)
+ end
+ end
+
+ context 'with the reblogs option set to true' do
+ before do
+ @me.follow!(@you, reblogs: true)
+ end
+
+ it 'does not mute reblogs' do
+ expect(@me.muting_reblogs?(@you)).to be(false)
end
end
end
RSpec.describe FollowRequest, type: :model do
describe '#authorize!' do
- expect(account).to receive(:follow!).with(target_account)
+ let(:follow_request) { Fabricate(:follow_request, account: account, target_account: target_account) }
+ let(:account) { Fabricate(:account) }
+ let(:target_account) { Fabricate(:account) }
+
+ it 'calls Account#follow!, MergeWorker.perform_async, and #destroy!' do
++ expect(account).to receive(:follow!).with(target_account, reblogs: true)
+ expect(MergeWorker).to receive(:perform_async).with(target_account.id, account.id)
+ expect(follow_request).to receive(:destroy!)
+ follow_request.authorize!
+ end
++
+ it 'generates a Follow' do
+ follow_request = Fabricate.create(:follow_request)
+ follow_request.authorize!
+ target = follow_request.target_account
+ expect(follow_request.account.following?(target)).to be true
+ end
+
+ it 'correctly passes show_reblogs when true' do
+ follow_request = Fabricate.create(:follow_request, show_reblogs: true)
+ follow_request.authorize!
+ target = follow_request.target_account
+ expect(follow_request.account.muting_reblogs?(target)).to be false
+ end
+
+ it 'correctly passes show_reblogs when false' do
+ follow_request = Fabricate.create(:follow_request, show_reblogs: false)
+ follow_request.authorize!
+ target = follow_request.target_account
+ expect(follow_request.account.muting_reblogs?(target)).to be true
+ end
end
-
- describe '#reject!'
-
- describe 'validations' do
- it 'has a valid fabricator' do
- follow_request = Fabricate.build(:follow_request)
- expect(follow_request).to be_valid
- end
-
- it 'is invalid without an account' do
- follow_request = Fabricate.build(:follow_request, account: nil)
- follow_request.valid?
- expect(follow_request).to model_have_error_on_field(:account)
- end
-
- it 'is invalid without a target account' do
- follow_request = Fabricate.build(:follow_request, target_account: nil)
- follow_request.valid?
- expect(follow_request).to model_have_error_on_field(:target_account)
- end
- end
end
is_expected.to_not change(Notification, :count)
end
+ describe 'reblogs' do
+ let(:status) { Fabricate(:status, account: Fabricate(:account)) }
+ let(:activity) { Fabricate(:status, account: sender, reblog: status) }
+
+ it 'shows reblogs by default' do
+ recipient.follow!(sender)
+ is_expected.to change(Notification, :count)
+ end
+
+ it 'shows reblogs when explicitly enabled' do
+ recipient.follow!(sender, reblogs: true)
+ is_expected.to change(Notification, :count)
+ end
+
+ it 'hides reblogs when disabled' do
+ recipient.follow!(sender, reblogs: false)
+ is_expected.to_not change(Notification, :count)
+ end
+ end
++
+ context 'for direct messages' do
+ let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct)) }
+
+ before do
+ user.settings.interactions = user.settings.interactions.merge('must_be_following_dm' => enabled)
+ end
+
+ context 'if recipient is supposed to be following sender' do
+ let(:enabled) { true }
+
+ it 'does not notify' do
+ is_expected.to_not change(Notification, :count)
+ end
+
+ context 'if the message chain initiated by recipient' do
+ let(:reply_to) { Fabricate(:status, account: recipient) }
+ let(:activity) { Fabricate(:mention, account: recipient, status: Fabricate(:status, account: sender, visibility: :direct, thread: reply_to)) }
+
+ it 'does notify' do
+ is_expected.to change(Notification, :count)
+ end
+ end
+ end
+
+ context 'if recipient is NOT supposed to be following sender' do
+ let(:enabled) { false }
+
+ it 'does notify' do
+ is_expected.to change(Notification, :count)
+ end
+ end
+ end
context do
let(:asshole) { Fabricate(:account, username: 'asshole') }