diff options
Diffstat (limited to 'app/javascript')
74 files changed, 5210 insertions, 251 deletions
diff --git a/app/javascript/glitch/actions/local_settings.js b/app/javascript/glitch/actions/local_settings.js new file mode 100644 index 000000000..93c5a9a17 --- /dev/null +++ b/app/javascript/glitch/actions/local_settings.js @@ -0,0 +1,93 @@ +/* + +`actions/local_settings` +======================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides our Redux actions related to local settings. It +consists of the following: + + - __`changesLocalSetting(key, value)` :__ + Changes the local setting with the given `key` to the given + `value`. `key` **MUST** be an array of strings, as required by + `Immutable.Map.prototype.getIn()`. + + - __`saveLocalSettings()` :__ + Saves the local settings to `localStorage` as a JSON object. We + shouldn't ever need to call this ourselves. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Constants: +---------- + +We provide the following constants: + + - __`LOCAL_SETTING_CHANGE` :__ + This string constant is used to dispatch a setting change to our + reducer in `reducers/local_settings`, where the setting is + actually changed. + +*/ + +export const LOCAL_SETTING_CHANGE = 'LOCAL_SETTING_CHANGE'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +`changeLocalSetting(key, value)`: +--------------------------------- + +Changes the local setting with the given `key` to the given `value`. +`key` **MUST** be an array of strings, as required by +`Immutable.Map.prototype.getIn()`. + +To accomplish this, we just dispatch a `LOCAL_SETTING_CHANGE` to our +reducer in `reducers/local_settings`. + +*/ + +export function changeLocalSetting(key, value) { + return dispatch => { + dispatch({ + type: LOCAL_SETTING_CHANGE, + key, + value, + }); + + dispatch(saveLocalSettings()); + }; +}; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +`saveLocalSettings()`: +---------------------- + +Saves the local settings to `localStorage` as a JSON object. +`changeLocalSetting()` calls this whenever it changes a setting. We +shouldn't ever need to call this ourselves. + +> __TODO :__ +> Right now `saveLocalSettings()` doesn't keep track of which user +> is currently signed in, but it might be better to give each user +> their *own* local settings. + +*/ + +export function saveLocalSettings() { + return (_, getState) => { + const localSettings = getState().get('local_settings').toJS(); + localStorage.setItem('mastodon-settings', JSON.stringify(localSettings)); + }; +}; diff --git a/app/javascript/glitch/components/account/header.js b/app/javascript/glitch/components/account/header.js new file mode 100644 index 000000000..b79140c02 --- /dev/null +++ b/app/javascript/glitch/components/account/header.js @@ -0,0 +1,241 @@ +/* + +`<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 escapeTextContentForBrowser from 'escape-html'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // +import emojify from '../../../mastodon/emoji'; +import IconButton from '../../../mastodon/components/icon_button'; +import Avatar from '../../../mastodon/components/avatar'; + +// 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, + me : PropTypes.number.isRequired, + onFollow : PropTypes.func.isRequired, + intl : PropTypes.object.isRequired, + }; + +/* + +### `render()` + +The `render()` function is used to render our component. + +*/ + + render () { + const { account, me, 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'); + let info = ''; + let actionBtn = ''; + let following = false; + + if (displayName.length === 0) { + displayName = account.get('username'); + } + +/* + +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} + title={intl.formatMessage(following ? messages.unfollow : messages.follow)} + onClick={this.props.onFollow} + /> + </div> + ); + } + } + +/* + +`displayNameHTML` processes the `displayName` and prepares it for +insertion into the document. Meanwhile, we extract the `text` and +`metadata` from our account's `note` using `processBio()`. + +*/ + + const displayNameHTML = { + __html : emojify(escapeTextContentForBrowser(displayName)), + }; + 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 + src={account.get('avatar')} + staticSrc={account.get('avatar_static')} + size={90} + /> + </span> + <span + className='account__header__display-name' + dangerouslySetInnerHTML={displayNameHTML} + /> + </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> + ); + } + +} diff --git a/app/javascript/glitch/components/compose/advanced_options/container.js b/app/javascript/glitch/components/compose/advanced_options/container.js new file mode 100644 index 000000000..160f22737 --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/container.js @@ -0,0 +1,66 @@ +/* + +`<ComposeAdvancedOptionsContainer>` +=================================== + +This container connects `<ComposeAdvancedOptions>` to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Mastodon imports // +import { toggleComposeAdvancedOption } from '../../../../mastodon/actions/compose'; + +// Our imports // +import ComposeAdvancedOptions from '.'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +State mapping: +-------------- + +The `mapStateToProps()` function maps various state properties to the +props of our component. The only property we care about is +`compose.advanced_options`. + +*/ + +const mapStateToProps = state => ({ + values: state.getIn(['compose', 'advanced_options']), +}); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We just need to provide a dispatch for +when an advanced option toggle changes. + +*/ + +const mapDispatchToProps = dispatch => ({ + + onChange (option) { + dispatch(toggleComposeAdvancedOption(option)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions); diff --git a/app/javascript/glitch/components/compose/advanced_options/index.js b/app/javascript/glitch/components/compose/advanced_options/index.js new file mode 100644 index 000000000..b745d1cdf --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/index.js @@ -0,0 +1,241 @@ +/* + +`<ComposeAdvancedOptions>` +========================== + +> For more information on the contents of this file, please contact: +> +> - surinna [@srn@dev.glitch.social] + +This adds an advanced options dropdown to the toot compose box, for +toggles that don't necessarily fit elsewhere. + +__Props:__ + + - __`values` (`ImmutablePropTypes.contains(…).isRequired`) :__ + An Immutable map with the following values: + + - __`do_not_federate` (`PropTypes.bool.isRequired`) :__ + Specifies whether or not to federate the status. + + - __`onChange` (`PropTypes.func.isRequired`) :__ + The function to call when a toggle is changed. We pass this from + our container to the toggle. + + - __`intl` (`PropTypes.object.isRequired`) :__ + Our internationalization object, inserted by `@injectIntl`. + +__State:__ + + - __`open` :__ + This tells whether the dropdown is currently open or closed. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; + +// Mastodon imports // +import IconButton from '../../../../mastodon/components/icon_button'; + +// Our imports // +import ComposeAdvancedOptionsToggle from './toggle'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Inital setup: +------------- + +The `messages` constant is used to define any messages that we need +from inside props. These are the various titles and labels on our +toggles. + +`iconStyle` styles the icon used for the dropdown button. + +*/ + +const messages = defineMessages({ + local_only_short : + { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' }, + local_only_long : + { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' }, + advanced_options_icon_title : + { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' }, +}); + +const iconStyle = { + height : null, + lineHeight : '27px', +}; + +/* + +Implementation: +--------------- + +*/ + +@injectIntl +export default class ComposeAdvancedOptions extends React.PureComponent { + + static propTypes = { + values : ImmutablePropTypes.contains({ + do_not_federate : PropTypes.bool.isRequired, + }).isRequired, + onChange : PropTypes.func.isRequired, + intl : PropTypes.object.isRequired, + }; + + state = { + open: false, + }; + +/* + +### `onToggleDropdown()` + +This function toggles the opening and closing of the advanced options +dropdown. + +*/ + + onToggleDropdown = () => { + this.setState({ open: !this.state.open }); + }; + +/* + +### `onGlobalClick(e)` + +This function closes the advanced options dropdown if you click +anywhere else on the screen. + +*/ + + onGlobalClick = (e) => { + if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) { + this.setState({ open: false }); + } + } + +/* + +### `componentDidMount()`, `componentWillUnmount()` + +This function closes the advanced options dropdown if you click +anywhere else on the screen. + +*/ + + componentDidMount () { + window.addEventListener('click', this.onGlobalClick); + window.addEventListener('touchstart', this.onGlobalClick); + } + componentWillUnmount () { + window.removeEventListener('click', this.onGlobalClick); + window.removeEventListener('touchstart', this.onGlobalClick); + } + +/* + +### `setRef(c)` + +`setRef()` stores a reference to the dropdown's `<div> in `this.node`. + +*/ + + setRef = (c) => { + this.node = c; + } + +/* + +### `render()` + +`render()` actually puts our component on the screen. + +*/ + + render () { + const { open } = this.state; + const { intl, values } = this.props; + +/* + +The `options` array provides all of the available advanced options +alongside their icon, text, and name. + +*/ + const options = [ + { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' }, + ]; + +/* + +`anyEnabled` tells us if any of our advanced options have been enabled. + +*/ + + const anyEnabled = values.some((enabled) => enabled); + +/* + +`optionElems` takes our `options` and creates +`<ComposeAdvancedOptionsToggle>`s out of them. We use the `name` of the +toggle as its `key` so that React can keep track of it. + +*/ + + const optionElems = options.map((option) => { + return ( + <ComposeAdvancedOptionsToggle + onChange={this.props.onChange} + active={values.get(option.name)} + key={option.name} + name={option.name} + shortText={intl.formatMessage(option.shortText)} + longText={intl.formatMessage(option.longText)} + /> + ); + }); + +/* + +Finally, we can render our component. + +*/ + + return ( + <div ref={this.setRef} className={`advanced-options-dropdown ${open ? 'open' : ''} ${anyEnabled ? 'active' : ''} `}> + <div className='advanced-options-dropdown__value'> + <IconButton + className='advanced-options-dropdown__value' + title={intl.formatMessage(messages.advanced_options_icon_title)} + icon='ellipsis-h' active={open || anyEnabled} + size={18} + style={iconStyle} + onClick={this.onToggleDropdown} + /> + </div> + <div className='advanced-options-dropdown__dropdown'> + {optionElems} + </div> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/compose/advanced_options/toggle.js b/app/javascript/glitch/components/compose/advanced_options/toggle.js new file mode 100644 index 000000000..d6907472a --- /dev/null +++ b/app/javascript/glitch/components/compose/advanced_options/toggle.js @@ -0,0 +1,103 @@ +/* + +`<ComposeAdvancedOptionsToggle>` +================================ + +> For more information on the contents of this file, please contact: +> +> - surinna [@srn@dev.glitch.social] + +This creates the toggle used by `<ComposeAdvancedOptions>`. + +__Props:__ + + - __`onChange` (`PropTypes.func`) :__ + This provides the function to call when the toggle is + (de-?)activated. + + - __`active` (`PropTypes.bool`) :__ + This prop controls whether the toggle is currently active or not. + + - __`name` (`PropTypes.string`) :__ + This identifies the toggle, and is sent to `onChange()` when it is + called. + + - __`shortText` (`PropTypes.string`) :__ + This is a short string used as the title of the toggle. + + - __`longText` (`PropTypes.string`) :__ + This is a longer string used as a subtitle for the toggle. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import Toggle from 'react-toggle'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Implementation: +--------------- + +*/ + +export default class ComposeAdvancedOptionsToggle extends React.PureComponent { + + static propTypes = { + onChange: PropTypes.func.isRequired, + active: PropTypes.bool.isRequired, + name: PropTypes.string.isRequired, + shortText: PropTypes.string.isRequired, + longText: PropTypes.string.isRequired, + } + +/* + +### `onToggle()` + +The `onToggle()` function simply calls the `onChange()` prop with the +toggle's `name`. + +*/ + + onToggle = () => { + this.props.onChange(this.props.name); + } + +/* + +### `render()` + +The `render()` function is used to render our component. We just render +a `<Toggle>` and place next to it our text. + +*/ + + render() { + const { active, shortText, longText } = this.props; + return ( + <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}> + <div className='advanced-options-dropdown__option__toggle'> + <Toggle checked={active} onChange={this.onToggle} /> + </div> + <div className='advanced-options-dropdown__option__content'> + <strong>{shortText}</strong> + {longText} + </div> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/notification/container.js b/app/javascript/glitch/components/notification/container.js new file mode 100644 index 000000000..bed086172 --- /dev/null +++ b/app/javascript/glitch/components/notification/container.js @@ -0,0 +1,73 @@ +/* + +`<NotificationContainer>` +========================= + +This container connects `<Notification>`s to the Redux store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { connect } from 'react-redux'; + +// Mastodon imports // +import { makeGetNotification } from '../../../mastodon/selectors'; + +// Our imports // +import Notification from '.'; +import { deleteNotification } from '../../../mastodon/actions/notifications'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +State mapping: +-------------- + +The `mapStateToProps()` function maps various state properties to the +props of our component. We wrap this in `makeMapStateToProps()` so that +we only have to call `makeGetNotification()` once instead of every +time. + +*/ + +const makeMapStateToProps = () => { + const getNotification = makeGetNotification(); + + const mapStateToProps = (state, props) => ({ + notification: getNotification(state, props.notification, props.accountId), + settings: state.get('local_settings'), + }); + + return mapStateToProps; +}; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Dispatch mapping: +----------------- + +The `mapDispatchToProps()` function maps dispatches to our store to the +various props of our component. We only need to provide a dispatch for +deleting notifications. + +*/ + +const mapDispatchToProps = dispatch => ({ + onDeleteNotification (id) { + dispatch(deleteNotification(id)); + }, +}); + +export default connect(makeMapStateToProps, mapDispatchToProps)(Notification); diff --git a/app/javascript/glitch/components/notification/follow.js b/app/javascript/glitch/components/notification/follow.js new file mode 100644 index 000000000..26396478b --- /dev/null +++ b/app/javascript/glitch/components/notification/follow.js @@ -0,0 +1,171 @@ +/* + +`<NotificationFollow>` +====================== + +This component renders a follow notification. + +__Props:__ + + - __`id` (`PropTypes.number.isRequired`) :__ + This is the id of the notification. + + - __`onDeleteNotification` (`PropTypes.func.isRequired`) :__ + The function to call when a notification should be + dismissed/deleted. + + - __`account` (`PropTypes.object.isRequired`) :__ + The account associated with the follow notification, ie the account + which followed the user. + + - __`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, FormattedMessage, injectIntl } from 'react-intl'; +import escapeTextContentForBrowser from 'escape-html'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Mastodon imports // +import emojify from '../../../mastodon/emoji'; +import Permalink from '../../../mastodon/components/permalink'; +import AccountContainer from '../../../mastodon/containers/account_container'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Inital setup: +------------- + +The `messages` constant is used to define any messages that we need +from inside props. + +*/ + +const messages = defineMessages({ + deleteNotification : + { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, +}); + +/* + +Implementation: +--------------- + +*/ + +@injectIntl +export default class NotificationFollow extends ImmutablePureComponent { + + static propTypes = { + id : PropTypes.number.isRequired, + onDeleteNotification : PropTypes.func.isRequired, + account : ImmutablePropTypes.map.isRequired, + intl : PropTypes.object.isRequired, + }; + +/* + +### `handleNotificationDeleteClick()` + +This function just calls our `onDeleteNotification()` prop with the +notification's `id`. + +*/ + + handleNotificationDeleteClick = () => { + this.props.onDeleteNotification(this.props.id); + } + +/* + +### `render()` + +This actually renders the component. + +*/ + + render () { + const { account, intl } = this.props; + +/* + +`dismiss` creates the notification dismissal button. Its title is given +by `dismissTitle`. + +*/ + + const dismissTitle = intl.formatMessage(messages.deleteNotification); + const dismiss = ( + <button + aria-label={dismissTitle} + title={dismissTitle} + onClick={this.handleNotificationDeleteClick} + className='status__prepend-dismiss-button' + > + <i className='fa fa-eraser' /> + </button> + ); + +/* + +`link` is a container for the account's `displayName`, which links to +the account timeline using a `<Permalink>`. + +*/ + + const displayName = account.get('display_name') || account.get('username'); + const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) }; + const link = ( + <Permalink + className='notification__display-name' + href={account.get('url')} + title={account.get('acct')} + to={`/accounts/${account.get('id')}`} + dangerouslySetInnerHTML={displayNameHTML} + /> + ); + +/* + +We can now render our component. + +*/ + + return ( + <div className='notification notification-follow'> + <div className='notification__message'> + <div className='notification__favourite-icon-wrapper'> + <i className='fa fa-fw fa-user-plus' /> + </div> + + <FormattedMessage + id='notification.follow' + defaultMessage='{name} followed you' + values={{ name: link }} + /> + + {dismiss} + </div> + + <AccountContainer id={account.get('id')} withNote={false} /> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js new file mode 100644 index 000000000..556d5aea8 --- /dev/null +++ b/app/javascript/glitch/components/notification/index.js @@ -0,0 +1,84 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; + +// Mastodon imports // + +// Our imports // +import StatusContainer from '../status/container'; +import NotificationFollow from './follow'; + +export default class Notification extends ImmutablePureComponent { + + static propTypes = { + notification: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, + onDeleteNotification: PropTypes.func.isRequired, + }; + + renderFollow (notification) { + return ( + <NotificationFollow + id={notification.get('id')} + account={notification.get('account')} + onDeleteNotification={this.props.onDeleteNotification} + /> + ); + } + + renderMention (notification) { + return ( + <StatusContainer + id={notification.get('status')} + notificationId={notification.get('id')} + withDismiss + /> + ); + } + + renderFavourite (notification) { + return ( + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + prepend='favourite' + muted + notificationId={notification.get('id')} + withDismiss + /> + ); + } + + renderReblog (notification) { + return ( + <StatusContainer + id={notification.get('status')} + account={notification.get('account')} + prepend='reblog' + muted + notificationId={notification.get('id')} + withDismiss + /> + ); + } + + render () { + const { notification } = this.props; + + switch(notification.get('type')) { + case 'follow': + return this.renderFollow(notification); + case 'mention': + return this.renderMention(notification); + case 'favourite': + return this.renderFavourite(notification); + case 'reblog': + return this.renderReblog(notification); + } + + return null; + } + +} diff --git a/app/javascript/glitch/components/settings/container.js b/app/javascript/glitch/components/settings/container.js new file mode 100644 index 000000000..6034935eb --- /dev/null +++ b/app/javascript/glitch/components/settings/container.js @@ -0,0 +1,27 @@ +// Package imports // +import { connect } from 'react-redux'; + +// Mastodon imports // +import { closeModal } from '../../../mastodon/actions/modal'; + +// Our imports // +import { changeLocalSetting } from '../../actions/local_settings'; +import Settings from '../../components/settings'; + +const mapStateToProps = state => ({ + settings: state.get('local_settings'), +}); + +const mapDispatchToProps = dispatch => ({ + toggleSetting (setting, e) { + dispatch(changeLocalSetting(setting, e.target.checked)); + }, + changeSetting (setting, e) { + dispatch(changeLocalSetting(setting, e.target.value)); + }, + onClose () { + dispatch(closeModal()); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Settings); diff --git a/app/javascript/glitch/components/settings/index.js b/app/javascript/glitch/components/settings/index.js new file mode 100644 index 000000000..afe7e9a87 --- /dev/null +++ b/app/javascript/glitch/components/settings/index.js @@ -0,0 +1,221 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; + +// Our imports // +import SettingsItem from './item'; + +const messages = defineMessages({ + layout_auto: { id: 'layout.auto', defaultMessage: 'Auto' }, + layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' }, + layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' }, +}); + +@injectIntl +export default class Settings extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + toggleSetting: PropTypes.func.isRequired, + changeSetting: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + currentIndex: 0, + }; + + General = () => { + const { intl } = this.props; + return ( + <div> + <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1> + <SettingsItem + settings={this.props.settings} + item={['layout']} + id='mastodon-settings--layout' + options={[ + { value: 'auto', message: intl.formatMessage(messages.layout_auto) }, + { value: 'multiple', message: intl.formatMessage(messages.layout_desktop) }, + { value: 'single', message: intl.formatMessage(messages.layout_mobile) }, + ]} + onChange={this.props.changeSetting} + > + <FormattedMessage id='settings.layout' defaultMessage='Layout:' /> + </SettingsItem> + + <SettingsItem + settings={this.props.settings} + item={['stretch']} + id='mastodon-settings--stretch' + onChange={this.props.toggleSetting} + > + <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' /> + </SettingsItem> + + </div> + ); + } + + CollapsedStatuses = () => { + return ( + <div> + <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'enabled']} + id='mastodon-settings--collapsed-enabled' + onChange={this.props.toggleSetting} + > + <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' /> + </SettingsItem> + <section> + <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'all']} + id='mastodon-settings--collapsed-auto-all' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'notifications']} + id='mastodon-settings--collapsed-auto-notifications' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'lengthy']} + id='mastodon-settings--collapsed-auto-lengthy' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'replies']} + id='mastodon-settings--collapsed-auto-replies' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'auto', 'media']} + id='mastodon-settings--collapsed-auto-media' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + dependsOnNot={[['collapsed', 'auto', 'all']]} + > + <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' /> + </SettingsItem> + </section> + <section> + <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'backgrounds', 'user_backgrounds']} + id='mastodon-settings--collapsed-user-backgrouns' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['collapsed', 'backgrounds', 'preview_images']} + id='mastodon-settings--collapsed-preview-images' + onChange={this.props.toggleSetting} + dependsOn={[['collapsed', 'enabled']]} + > + <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' /> + </SettingsItem> + </section> + </div> + ); + } + + Media = () => { + return ( + <div> + <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1> + <SettingsItem + settings={this.props.settings} + item={['media', 'letterbox']} + id='mastodon-settings--media-letterbox' + onChange={this.props.toggleSetting} + > + <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' /> + </SettingsItem> + <SettingsItem + settings={this.props.settings} + item={['media', 'fullwidth']} + id='mastodon-settings--media-fullwidth' + onChange={this.props.toggleSetting} + > + <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' /> + </SettingsItem> + </div> + ); + } + + navigateTo = (e) => + this.setState({ currentIndex: +e.currentTarget.getAttribute('data-mastodon-navigation_index') }); + + render () { + + const { General, CollapsedStatuses, Media, navigateTo } = this; + const { onClose } = this.props; + const { currentIndex } = this.state; + + return ( + <div className='modal-root__modal settings-modal'> + + <nav className='settings-modal__navigation'> + <a onClick={navigateTo} role='button' data-mastodon-navigation_index='0' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 0 ? ' active' : ''}`}> + <FormattedMessage id='settings.general' defaultMessage='General' /> + </a> + <a onClick={navigateTo} role='button' data-mastodon-navigation_index='1' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 1 ? ' active' : ''}`}> + <FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /> + </a> + <a onClick={navigateTo} role='button' data-mastodon-navigation_index='2' tabIndex='0' className={`settings-modal__navigation-item${currentIndex === 2 ? ' active' : ''}`}> + <FormattedMessage id='settings.media' defaultMessage='Media' /> + </a> + <a href='/settings/preferences' className='settings-modal__navigation-item'> + <i className='fa fa-fw fa-cog' /> <FormattedMessage id='settings.preferences' defaultMessage='User preferences' /> + </a> + <a onClick={onClose} role='button' tabIndex='0' className='settings-modal__navigation-close'> + <FormattedMessage id='settings.close' defaultMessage='Close' /> + </a> + + </nav> + + <div className='settings-modal__content'> + { + [ + <General />, + <CollapsedStatuses />, + <Media />, + ][currentIndex] || <General /> + } + </div> + + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/settings/item.js b/app/javascript/glitch/components/settings/item.js new file mode 100644 index 000000000..4c67cc2ac --- /dev/null +++ b/app/javascript/glitch/components/settings/item.js @@ -0,0 +1,79 @@ +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +export default class SettingsItem extends React.PureComponent { + + static propTypes = { + settings: ImmutablePropTypes.map.isRequired, + item: PropTypes.array.isRequired, + id: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.string.isRequired, + message: PropTypes.object.isRequired, + })), + dependsOn: PropTypes.array, + dependsOnNot: PropTypes.array, + children: PropTypes.element.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleChange = (e) => { + const { item, onChange } = this.props; + onChange(item, e); + } + + render () { + const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props; + let enabled = true; + + if (dependsOn) { + for (let i = 0; i < dependsOn.length; i++) { + enabled = enabled && settings.getIn(dependsOn[i]); + } + } + if (dependsOnNot) { + for (let i = 0; i < dependsOnNot.length; i++) { + enabled = enabled && !settings.getIn(dependsOnNot[i]); + } + } + + if (options && options.length > 0) { + const currentValue = settings.getIn(item); + const optionElems = options && options.length > 0 && options.map((opt) => ( + <option key={opt.value} selected={currentValue === opt.value} value={opt.value} > + {opt.message} + </option> + )); + return ( + <label htmlFor={id}> + <p>{children}</p> + <p> + <select + id={id} + disabled={!enabled} + onBlur={this.handleChange} + > + {optionElems} + </select> + </p> + </label> + ); + } else { + return ( + <label htmlFor={id}> + <input + id={id} + type='checkbox' + checked={settings.getIn(item)} + onChange={this.handleChange} + disabled={!enabled} + /> + {children} + </label> + ); + } + } + +} diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js new file mode 100644 index 000000000..df0904a7c --- /dev/null +++ b/app/javascript/glitch/components/status/action_bar.js @@ -0,0 +1,168 @@ +// 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 DropdownMenu from '../../../mastodon/components/dropdown_menu'; + +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' }, + 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' }, + deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, +}); + +@injectIntl +export default class StatusActionBar extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + notificationId: PropTypes.number, + onReply: PropTypes.func, + onFavourite: PropTypes.func, + onReblog: PropTypes.func, + onDelete: PropTypes.func, + onMention: PropTypes.func, + onMute: PropTypes.func, + onBlock: PropTypes.func, + onReport: PropTypes.func, + onMuteConversation: PropTypes.func, + onDeleteNotification: PropTypes.func, + me: PropTypes.number, + 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', + 'me', + 'withDismiss', + ] + + handleReplyClick = () => { + this.props.onReply(this.props.status, this.context.router.history); + } + + handleFavouriteClick = () => { + this.props.onFavourite(this.props.status); + } + + handleReblogClick = (e) => { + this.props.onReblog(this.props.status, e); + } + + handleDeleteClick = () => { + this.props.onDelete(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')}`); + } + + handleReport = () => { + this.props.onReport(this.props.status); + } + + handleConversationMuteClick = () => { + this.props.onMuteConversation(this.props.status); + } + + handleNotificationDeleteClick = () => { + this.props.onDeleteNotification(this.props.notificationId); + } + + render () { + const { status, me, intl, withDismiss } = this.props; + const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + + let menu = []; + let reblogIcon = 'retweet'; + let replyIcon; + let replyTitle; + + menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen }); + menu.push(null); + + if (withDismiss) { + menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); + menu.push({ text: intl.formatMessage(messages.deleteNotification), action: this.handleNotificationDeleteClick }); + menu.push(null); + } + + if (status.getIn(['account', 'id']) === me) { + 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('visibility') === 'direct') { + reblogIcon = 'envelope'; + } else if (status.get('visibility') === 'private') { + reblogIcon = 'lock'; + } + */ + + 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); + } + + 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 || reblogDisabled} active={status.get('reblogged')} title={reblogDisabled ? 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')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> + + <div className='status__action-bar-dropdown'> + <DropdownMenu items={menu} disabled={anonymousAccess} 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> + ); + } + +} diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js new file mode 100644 index 000000000..c45b2e0ec --- /dev/null +++ b/app/javascript/glitch/components/status/container.js @@ -0,0 +1,256 @@ +/* + +`<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, +} from '../../../mastodon/actions/interactions'; +import { + blockAccount, + muteAccount, +} from '../../../mastodon/actions/accounts'; +import { + muteStatus, + unmuteStatus, + deleteStatus, +} from '../../../mastodon/actions/statuses'; +import { initReport } from '../../../mastodon/actions/reports'; +import { openModal } from '../../../mastodon/actions/modal'; +import { deleteNotification } from '../../../mastodon/actions/notifications'; + +// 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', + }, + muteConfirm : { + id : 'confirmations.mute.confirm', + defaultMessage : 'Mute', + }, +}); + + /* * * * */ + +/* + +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); + 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, + me : state.getIn(['meta', 'me']), + settings : state.get('local_settings'), + prepend : prepend || ownProps.prepend, + reblogModal : state.getIn(['meta', 'boost_modal']), + deleteModal : state.getIn(['meta', 'delete_modal']), + autoPlayGif : state.getIn(['meta', 'auto_play_gif']), + }; + }; + + 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)); + } + }, + + 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(openModal('CONFIRM', { + message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />, + confirm: intl.formatMessage(messages.muteConfirm), + onConfirm: () => dispatch(muteAccount(account.get('id'))), + })); + }, + + onMuteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + + onDeleteNotification (id) { + dispatch(deleteNotification(id)); + }, +}); + +export default injectIntl( + connect(makeMapStateToProps, mapDispatchToProps)(Status) +); diff --git a/app/javascript/glitch/components/status/content.js b/app/javascript/glitch/components/status/content.js new file mode 100644 index 000000000..06fe04ce0 --- /dev/null +++ b/app/javascript/glitch/components/status/content.js @@ -0,0 +1,247 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import escapeTextContentForBrowser from 'escape-html'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import classnames from 'classnames'; + +// Mastodon imports // +import emojify from '../../../mastodon/emoji'; +import { isRtl } from '../../../mastodon/rtl'; +import Permalink from '../../../mastodon/components/permalink'; + +export default class StatusContent extends React.PureComponent { + + static propTypes = { + status: ImmutablePropTypes.map.isRequired, + expanded: PropTypes.oneOf([true, false, null]), + setExpansion: PropTypes.func, + onHeightUpdate: PropTypes.func, + media: PropTypes.element, + mediaIcon: PropTypes.string, + parseClick: PropTypes.func, + disabled: PropTypes.bool, + }; + + state = { + hidden: true, + }; + + componentDidMount () { + const node = this.node; + const links = node.querySelectorAll('a'); + + for (var i = 0; i < links.length; ++i) { + let link = links[i]; + let mention = this.props.status.get('mentions').find(item => link.href === item.get('url')); + + if (mention) { + link.addEventListener('click', this.onMentionClick.bind(this, mention), false); + link.setAttribute('title', mention.get('acct')); + } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) { + link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false); + } else { + link.addEventListener('click', this.onLinkClick.bind(this), false); + link.setAttribute('title', link.href); + } + + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + } + } + + componentDidUpdate () { + if (this.props.onHeightUpdate) { + this.props.onHeightUpdate(); + } + } + + onLinkClick = (e) => { + if (this.props.expanded === false) { + if (this.props.parseClick) this.props.parseClick(e); + } + } + + onMentionClick = (mention, e) => { + if (this.props.parseClick) { + this.props.parseClick(e, `/accounts/${mention.get('id')}`); + } + } + + onHashtagClick = (hashtag, e) => { + hashtag = hashtag.replace(/^#/, '').toLowerCase(); + + if (this.props.parseClick) { + this.props.parseClick(e, `/timelines/tag/${hashtag}`); + } + } + + handleMouseDown = (e) => { + this.startXY = [e.clientX, e.clientY]; + } + + handleMouseUp = (e) => { + const { parseClick } = this.props; + + if (!this.startXY) { + return; + } + + const [ startX, startY ] = this.startXY; + const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)]; + + if (e.target.localName === 'button' || e.target.localName === 'a' || (e.target.parentNode && (e.target.parentNode.localName === 'button' || e.target.parentNode.localName === 'a'))) { + return; + } + + if (deltaX + deltaY < 5 && e.button === 0 && parseClick) { + parseClick(e); + } + + this.startXY = null; + } + + handleSpoilerClick = (e) => { + e.preventDefault(); + + if (this.props.setExpansion) { + this.props.setExpansion(this.props.expanded ? null : true); + } else { + this.setState({ hidden: !this.state.hidden }); + } + } + + setRef = (c) => { + this.node = c; + } + + render () { + const { + status, + media, + mediaIcon, + parseClick, + disabled, + } = this.props; + + const hidden = ( + this.props.setExpansion ? + !this.props.expanded : + this.state.hidden + ); + + const content = { __html: emojify(status.get('content')) }; + const spoilerContent = { + __html: emojify(escapeTextContentForBrowser( + status.get('spoiler_text', '') + )), + }; + const directionStyle = { direction: 'ltr' }; + const classNames = classnames('status__content', { + 'status__content--with-action': parseClick && !disabled, + }); + + if (isRtl(status.get('search_index'))) { + directionStyle.direction = 'rtl'; + } + + if (status.get('spoiler_text').length > 0) { + let mentionsPlaceholder = ''; + + const mentionLinks = status.get('mentions').map(item => ( + <Permalink + to={`/accounts/${item.get('id')}`} + href={item.get('url')} + key={item.get('id')} + className='mention' + > + @<span>{item.get('username')}</span> + </Permalink> + )).reduce((aggregate, item) => [...aggregate, item, ' '], []); + + const toggleText = hidden ? [ + <FormattedMessage + id='status.show_more' + defaultMessage='Show more' + key='0' + />, + mediaIcon ? ( + <i + className={ + `fa fa-fw fa-${mediaIcon} status__content__spoiler-icon` + } + aria-hidden='true' + key='1' + /> + ) : null, + ] : [ + <FormattedMessage + id='status.show_less' + defaultMessage='Show less' + key='0' + />, + ]; + + if (hidden) { + mentionsPlaceholder = <div>{mentionLinks}</div>; + } + + return ( + <div className={classNames} ref={this.setRef}> + <p + style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + > + <span dangerouslySetInnerHTML={spoilerContent} /> + {' '} + <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> + {toggleText} + </button> + </p> + + {mentionsPlaceholder} + + <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> + <div + style={directionStyle} + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + {media} + </div> + + </div> + ); + } else if (parseClick) { + return ( + <div + ref={this.setRef} + className={classNames} + style={directionStyle} + > + <div + onMouseDown={this.handleMouseDown} + onMouseUp={this.handleMouseUp} + dangerouslySetInnerHTML={content} + /> + {media} + </div> + ); + } else { + return ( + <div + ref={this.setRef} + className='status__content' + style={directionStyle} + > + <div dangerouslySetInnerHTML={content} /> + {media} + </div> + ); + } + } + +} diff --git a/app/javascript/glitch/components/status/gallery/index.js b/app/javascript/glitch/components/status/gallery/index.js new file mode 100644 index 000000000..ae03dc08d --- /dev/null +++ b/app/javascript/glitch/components/status/gallery/index.js @@ -0,0 +1,79 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +// Mastodon imports // +import IconButton from '../../../../mastodon/components/icon_button'; + +// Our imports // +import StatusGalleryItem from './item'; + +const messages = defineMessages({ + toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, +}); + +@injectIntl +export default class StatusGallery extends React.PureComponent { + + static propTypes = { + sensitive: PropTypes.bool, + media: ImmutablePropTypes.list.isRequired, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + height: PropTypes.number.isRequired, + onOpenMedia: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + autoPlayGif: PropTypes.bool.isRequired, + }; + + state = { + visible: !this.props.sensitive, + }; + + handleOpen = () => { + this.setState({ visible: !this.state.visible }); + } + + handleClick = (index) => { + this.props.onOpenMedia(this.props.media, index); + } + + render () { + const { media, intl, sensitive, letterbox, fullwidth } = this.props; + + let children; + + if (!this.state.visible) { + let warning; + + if (sensitive) { + warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />; + } else { + warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />; + } + + children = ( + <div role='button' tabIndex='0' className='media-spoiler' onClick={this.handleOpen}> + <span className='media-spoiler__warning'>{warning}</span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + const size = media.take(4).size; + children = media.take(4).map((attachment, i) => <StatusGalleryItem key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} letterbox={letterbox} />); + } + + return ( + <div className={`media-gallery ${fullwidth ? 'full-width' : ''}`} style={{ height: `${this.props.height}px` }}> + <div className={`spoiler-button ${this.state.visible ? 'spoiler-button--visible' : ''}`}> + <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> + </div> + + {children} + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/gallery/item.js b/app/javascript/glitch/components/status/gallery/item.js new file mode 100644 index 000000000..d646825a3 --- /dev/null +++ b/app/javascript/glitch/components/status/gallery/item.js @@ -0,0 +1,132 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; + +// Mastodon imports // +import { isIOS } from '../../../../mastodon/is_mobile'; + +export default class StatusGalleryItem extends React.PureComponent { + + static propTypes = { + attachment: ImmutablePropTypes.map.isRequired, + index: PropTypes.number.isRequired, + size: PropTypes.number.isRequired, + letterbox: PropTypes.bool, + onClick: PropTypes.func.isRequired, + autoPlayGif: PropTypes.bool.isRequired, + }; + + handleClick = (e) => { + const { index, onClick } = this.props; + + if (e.button === 0) { + e.preventDefault(); + onClick(index); + } + + e.stopPropagation(); + } + + render () { + const { attachment, index, size, letterbox } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + + if (size === 1) { + width = 100; + } + + if (size === 4 || (size === 3 && index > 0)) { + height = 50; + } + + if (size === 2) { + if (index === 0) { + right = '2px'; + } else { + left = '2px'; + } + } else if (size === 3) { + if (index === 0) { + right = '2px'; + } else if (index > 0) { + left = '2px'; + } + + if (index === 1) { + bottom = '2px'; + } else if (index > 1) { + top = '2px'; + } + } else if (size === 4) { + if (index === 0 || index === 2) { + right = '2px'; + } + + if (index === 1 || index === 3) { + left = '2px'; + } + + if (index < 2) { + bottom = '2px'; + } else { + top = '2px'; + } + } + + let thumbnail = ''; + + if (attachment.get('type') === 'image') { + const previewUrl = attachment.get('preview_url'); + const previewWidth = attachment.getIn(['meta', 'small', 'width']); + + const originalUrl = attachment.get('url'); + const originalWidth = attachment.getIn(['meta', 'original', 'width']); + + const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`; + const sizes = `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw`; + + thumbnail = ( + <a + className='media-gallery__item-thumbnail' + href={attachment.get('remote_url') || originalUrl} + onClick={this.handleClick} + target='_blank' + > + <img className={letterbox ? 'letterbox' : ''} src={previewUrl} srcSet={srcSet} sizes={sizes} alt='' /> + </a> + ); + } else if (attachment.get('type') === 'gifv') { + const autoPlay = !isIOS() && this.props.autoPlayGif; + + thumbnail = ( + <div className={`media-gallery__gifv ${autoPlay ? 'autoplay' : ''}`}> + <video + className={`media-gallery__item-gifv-thumbnail${letterbox ? ' letterbox' : ''}`} + role='application' + src={attachment.get('url')} + onClick={this.handleClick} + autoPlay={autoPlay} + loop + muted + /> + + <span className='media-gallery__gifv__label'>GIF</span> + </div> + ); + } + + return ( + <div className='media-gallery__item' key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}> + {thumbnail} + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/header.js b/app/javascript/glitch/components/status/header.js new file mode 100644 index 000000000..3187fa7fb --- /dev/null +++ b/app/javascript/glitch/components/status/header.js @@ -0,0 +1,248 @@ +/* + +`<StatusHeader>` +================ + +Originally a part of `<Status>`, but extracted into a separate +component for better documentation and maintainance by +@kibi@glitch.social as a part of glitch-soc/mastodon. + +*/ + + /* * * * */ + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { defineMessages, injectIntl } from 'react-intl'; + +// Mastodon imports // +import Avatar from '../../../mastodon/components/avatar'; +import AvatarOverlay from '../../../mastodon/components/avatar_overlay'; +import DisplayName from '../../../mastodon/components/display_name'; +import IconButton from '../../../mastodon/components/icon_button'; + + /* * * * */ + +/* + +Inital setup: +------------- + +The `messages` constant is used to define any messages that we need +from inside props. In our case, these are the `collapse` and +`uncollapse` messages used with our collapse/uncollapse buttons. + +*/ + +const messages = defineMessages({ + collapse: { id: 'status.collapse', defaultMessage: 'Collapse' }, + uncollapse: { id: 'status.uncollapse', defaultMessage: 'Uncollapse' }, + public: { id: 'privacy.public.short', defaultMessage: 'Public' }, + unlisted: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + private: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + direct: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, +}); + + /* * * * */ + +/* + +The `<StatusHeader>` component: +------------------------------- + +The `<StatusHeader>` component wraps together the header information +(avatar, display name) and upper buttons and icons (collapsing, media +icons) into a single `<header>` element. + +### Props + + - __`account`, `friend` (`ImmutablePropTypes.map`) :__ + These give the accounts associated with the status. `account` is + the author of the post; `friend` will have their avatar appear + in the overlay if provided. + + - __`mediaIcon` (`PropTypes.string`) :__ + If a mediaIcon should be placed in the header, this string + specifies it. + + - __`collapsible`, `collapsed` (`PropTypes.bool`) :__ + These props tell whether a post can be, and is, collapsed. + + - __`parseClick` (`PropTypes.func`) :__ + This function will be called when the user clicks inside the header + information. + + - __`setExpansion` (`PropTypes.func`) :__ + This function is used to set the expansion state of the post. + + - __`intl` (`PropTypes.object`) :__ + This is our internationalization object, provided by + `injectIntl()`. + +*/ + +@injectIntl +export default class StatusHeader extends React.PureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + friend: ImmutablePropTypes.map, + mediaIcon: PropTypes.string, + collapsible: PropTypes.bool, + collapsed: PropTypes.bool, + parseClick: PropTypes.func.isRequired, + setExpansion: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + visibility: PropTypes.string, + }; + +/* + +### Implementation + +#### `handleCollapsedClick()`. + +`handleCollapsedClick()` is just a simple callback for our collapsing +button. It calls `setExpansion` to set the collapsed state of the +status. + +*/ + + handleCollapsedClick = (e) => { + const { collapsed, setExpansion } = this.props; + if (e.button === 0) { + setExpansion(collapsed ? null : false); + e.preventDefault(); + } + } + +/* + +#### `handleAccountClick()`. + +`handleAccountClick()` handles any clicks on the header info. It calls +`parseClick()` with our `account` as the anticipatory `destination`. + +*/ + + handleAccountClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/accounts/${+account.get('id')}`); + } + +/* + +#### `render()`. + +`render()` actually puts our element on the screen. `<StatusHeader>` +has a very straightforward rendering process. + +*/ + + render () { + const { + account, + friend, + mediaIcon, + collapsible, + collapsed, + intl, + visibility, + } = this.props; + + const visibilityClass = { + public: 'globe', + unlisted: 'unlock-alt', + private: 'lock', + direct: 'envelope', + }[visibility]; + + return ( + <header className='status__info'> + { + +/* + +We have to include the status icons before the header content because +it is rendered as a float. + +*/ + + } + <div className='status__info__icons'> + {mediaIcon ? ( + <i + className={`fa fa-fw fa-${mediaIcon}`} + aria-hidden='true' + /> + ) : null} + {( + <i + className={`status__visibility-icon fa fa-fw fa-${visibilityClass}`} + title={intl.formatMessage(messages[visibility])} + aria-hidden='true' + /> + )} + {collapsible ? ( + <IconButton + className='status__collapse-button' + animate flip + active={collapsed} + title={ + collapsed ? + intl.formatMessage(messages.uncollapse) : + intl.formatMessage(messages.collapse) + } + icon='angle-double-up' + onClick={this.handleCollapsedClick} + /> + ) : null} + </div> + { + +/* + +This begins our header content. It is all wrapped inside of a link +which gets handled by `handleAccountClick`. We use an `<AvatarOverlay>` +if we have a `friend` and a normal `<Avatar>` if we don't. + +*/ + + } + <a + href={account.get('url')} + target='_blank' + className='status__display-name' + onClick={this.handleAccountClick} + > + <div className='status__avatar'>{ + friend ? ( + <AvatarOverlay + staticSrc={account.get('avatar_static')} + overlaySrc={friend.get('avatar_static')} + /> + ) : ( + <Avatar + src={account.get('avatar')} + staticSrc={account.get('avatar_static')} + size={48} + /> + ) + }</div> + <DisplayName account={account} /> + </a> + + </header> + ); + } + +} diff --git a/app/javascript/glitch/components/status/index.js b/app/javascript/glitch/components/status/index.js new file mode 100644 index 000000000..4a91b5aa3 --- /dev/null +++ b/app/javascript/glitch/components/status/index.js @@ -0,0 +1,733 @@ +/* + +`<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'; + +// 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'; + + /* * * * */ + +/* + +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. + + - __`me` (`PropTypes.number`) :__ + This is the id of the currently-signed-in user. + + - __`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. + + - __`autoPlayGif` (`PropTypes.bool`) :__ + This tells the frontend whether or not to autoplay gifs! + + - __`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.number, + status : ImmutablePropTypes.map, + account : ImmutablePropTypes.map, + settings : ImmutablePropTypes.map, + me : PropTypes.number, + onFavourite : PropTypes.func, + onReblog : PropTypes.func, + onModalReblog : PropTypes.func, + onDelete : PropTypes.func, + onMention : PropTypes.func, + onMute : PropTypes.func, + onMuteConversation : PropTypes.func, + onBlock : PropTypes.func, + onReport : PropTypes.func, + onOpenMedia : PropTypes.func, + onOpenVideo : PropTypes.func, + onDeleteNotification : PropTypes.func, + reblogModal : PropTypes.bool, + deleteModal : PropTypes.bool, + autoPlayGif : PropTypes.bool, + muted : PropTypes.bool, + collapse : PropTypes.bool, + prepend : PropTypes.string, + withDismiss : PropTypes.bool, + notificationId : PropTypes.number, + intersectionObserverWrapper : PropTypes.object, + }; + + state = { + isExpanded : null, + isIntersecting : true, + isHidden : 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', + 'me', + 'boostModal', + 'autoPlayGif', + 'muted', + 'collapse', + ] + + updateOnStates = [ + 'isExpanded', + ] + +/* + +#### `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, + } = 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('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, + autoPlayGif, + ...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']); + } + + +/* + +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' : '' + }` + } + style={{ + backgroundImage: ( + isExpanded === false && background ? + `url(${background})` : + 'none' + ), + }} + ref={handleRef} + > + {prepend && account ? ( + <StatusPrepend + type={prepend} + account={account} + parseClick={parseClick} + notificationId={this.props.notificationId} + onDeleteNotification={this.props.onDeleteNotification} + /> + ) : null} + <StatusHeader + account={status.get('account')} + friend={account} + mediaIcon={mediaIcon} + visibility={status.get('visibility')} + 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} + </article> + ); + + } + +} diff --git a/app/javascript/glitch/components/status/player.js b/app/javascript/glitch/components/status/player.js new file mode 100644 index 000000000..cc65cd34e --- /dev/null +++ b/app/javascript/glitch/components/status/player.js @@ -0,0 +1,203 @@ +// Package imports // +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; + +// Mastodon imports // +import IconButton from '../../../mastodon/components/icon_button'; +import { isIOS } from '../../../mastodon/is_mobile'; + +const messages = defineMessages({ + toggle_sound: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' }, + toggle_visible: { id: 'video_player.toggle_visible', defaultMessage: 'Toggle visibility' }, + expand_video: { id: 'video_player.expand', defaultMessage: 'Expand video' }, +}); + +@injectIntl +export default class StatusPlayer extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + media: ImmutablePropTypes.map.isRequired, + letterbox: PropTypes.bool, + fullwidth: PropTypes.bool, + height: PropTypes.number, + sensitive: PropTypes.bool, + intl: PropTypes.object.isRequired, + autoplay: PropTypes.bool, + onOpenVideo: PropTypes.func.isRequired, + }; + + static defaultProps = { + height: 110, + }; + + state = { + visible: !this.props.sensitive, + preview: true, + muted: true, + hasAudio: true, + videoError: false, + }; + + handleClick = () => { + this.setState({ muted: !this.state.muted }); + } + + handleVideoClick = (e) => { + e.stopPropagation(); + + const node = this.video; + + if (node.paused) { + node.play(); + } else { + node.pause(); + } + } + + handleOpen = () => { + this.setState({ preview: !this.state.preview }); + } + + handleVisibility = () => { + this.setState({ + visible: !this.state.visible, + preview: true, + }); + } + + handleExpand = () => { + this.video.pause(); + this.props.onOpenVideo(this.props.media, this.video.currentTime); + } + + setRef = (c) => { + this.video = c; + } + + handleLoadedData = () => { + if (('WebkitAppearance' in document.documentElement.style && this.video.audioTracks.length === 0) || this.video.mozHasAudio === false) { + this.setState({ hasAudio: false }); + } + } + + handleVideoError = () => { + this.setState({ videoError: true }); + } + + componentDidMount () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + this.video.addEventListener('error', this.handleVideoError); + } + + componentDidUpdate () { + if (!this.video) { + return; + } + + this.video.addEventListener('loadeddata', this.handleLoadedData); + this.video.addEventListener('error', this.handleVideoError); + } + + componentWillUnmount () { + if (!this.video) { + return; + } + + this.video.removeEventListener('loadeddata', this.handleLoadedData); + this.video.removeEventListener('error', this.handleVideoError); + } + + render () { + const { media, intl, letterbox, fullwidth, height, sensitive, autoplay } = this.props; + + let spoilerButton = ( + <div className={`status__video-player-spoiler ${this.state.visible ? 'status__video-player-spoiler--visible' : ''}`}> + <IconButton overlay title={intl.formatMessage(messages.toggle_visible)} icon={this.state.visible ? 'eye' : 'eye-slash'} onClick={this.handleVisibility} /> + </div> + ); + + let expandButton = !this.context.router ? '' : ( + <div className='status__video-player-expand'> + <IconButton overlay title={intl.formatMessage(messages.expand_video)} icon='expand' onClick={this.handleExpand} /> + </div> + ); + + let muteButton = ''; + + if (this.state.hasAudio) { + muteButton = ( + <div className='status__video-player-mute'> + <IconButton overlay title={intl.formatMessage(messages.toggle_sound)} icon={this.state.muted ? 'volume-off' : 'volume-up'} onClick={this.handleClick} /> + </div> + ); + } + + if (!this.state.visible) { + if (sensitive) { + return ( + <div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}> + {spoilerButton} + <span className='media-spoiler__warning'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } else { + return ( + <div role='button' tabIndex='0' style={{ height: `${height}px` }} className={`media-spoiler ${fullwidth ? 'full-width' : ''}`} onClick={this.handleVisibility}> + {spoilerButton} + <span className='media-spoiler__warning'><FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' /></span> + <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> + </div> + ); + } + } + + if (this.state.preview && !autoplay) { + return ( + <div role='button' tabIndex='0' className={`media-spoiler-video ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px`, backgroundImage: `url(${media.get('preview_url')})` }} onClick={this.handleOpen}> + {spoilerButton} + <div className='media-spoiler-video-play-icon'><i className='fa fa-play' /></div> + </div> + ); + } + + if (this.state.videoError) { + return ( + <div style={{ height: `${height}px` }} className='video-error-cover' > + <span className='media-spoiler__warning'><FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' /></span> + </div> + ); + } + + return ( + <div className={`status__video-player ${fullwidth ? 'full-width' : ''}`} style={{ height: `${height}px` }}> + {spoilerButton} + {muteButton} + {expandButton} + + <video + className={`status__video-player-video${letterbox ? ' letterbox' : ''}`} + role='button' + tabIndex='0' + ref={this.setRef} + src={media.get('url')} + autoPlay={!isIOS()} + loop + muted={this.state.muted} + onClick={this.handleVideoClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/glitch/components/status/prepend.js b/app/javascript/glitch/components/status/prepend.js new file mode 100644 index 000000000..d9b04b5ec --- /dev/null +++ b/app/javascript/glitch/components/status/prepend.js @@ -0,0 +1,191 @@ +/* + +`<StatusPrepend>` +================= + +Originally a part of `<Status>`, but extracted into a separate +component for better documentation and maintainance by +@kibi@glitch.social as a part of glitch-soc/mastodon. + +*/ + + /* * * * */ + +/* + +Imports: +-------- + +*/ + +// Package imports // +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import escapeTextContentForBrowser from 'escape-html'; +import { defineMessages, injectIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; + +// Mastodon imports // +import emojify from '../../../mastodon/emoji'; + + +const messages = defineMessages({ + deleteNotification: { id: 'status.dismiss_notification', defaultMessage: 'Dismiss notification' }, +}); + + /* * * * */ + +/* + +The `<StatusPrepend>` component: +-------------------------------- + +The `<StatusPrepend>` component holds a status's prepend, ie the text +that says “X reblogged this,” etc. It is represented by an `<aside>` +element. + +### Props + + - __`type` (`PropTypes.string`) :__ + The type of prepend. One of `'reblogged_by'`, `'reblog'`, + `'favourite'`. + + - __`account` (`ImmutablePropTypes.map`) :__ + The account associated with the prepend. + + - __`parseClick` (`PropTypes.func.isRequired`) :__ + Our click parsing function. + +*/ + +@injectIntl +export default class StatusPrepend extends React.PureComponent { + + static propTypes = { + type: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + parseClick: PropTypes.func.isRequired, + notificationId: PropTypes.number, + onDeleteNotification: PropTypes.func, + intl: PropTypes.object.isRequired, + }; + +/* + +### Implementation + +#### `handleClick()`. + +This is just a small wrapper for `parseClick()` that gets fired when +an account link is clicked. + +*/ + + handleClick = (e) => { + const { account, parseClick } = this.props; + parseClick(e, `/accounts/${+account.get('id')}`); + } + + handleNotificationDeleteClick = () => { + this.props.onDeleteNotification(this.props.notificationId); + } + +/* + +#### `<Message>`. + +`<Message>` is a quick functional React component which renders the +actual prepend message based on our provided `type`. First we create a +`link` for the account's name, and then use `<FormattedMessage>` to +generate the message. + +*/ + + Message = () => { + const { type, account } = this.props; + let link = ( + <a + onClick={this.handleClick} + href={account.get('url')} + className='status__display-name' + > + <b + dangerouslySetInnerHTML={{ + __html : emojify(escapeTextContentForBrowser( + account.get('display_name') || account.get('username') + )), + }} + /> + </a> + ); + switch (type) { + case 'reblogged_by': + return ( + <FormattedMessage + id='status.reblogged_by' + defaultMessage='{name} boosted' + values={{ name : link }} + /> + ); + case 'favourite': + return ( + <FormattedMessage + id='notification.favourite' + defaultMessage='{name} favourited your status' + values={{ name : link }} + /> + ); + case 'reblog': + return ( + <FormattedMessage + id='notification.reblog' + defaultMessage='{name} boosted your status' + values={{ name : link }} + /> + ); + } + return null; + } + +/* + +#### `render()`. + +Our `render()` is incredibly simple; we just render the icon and then +the `<Message>` inside of an <aside>. + +*/ + + render () { + const { Message } = this; + const { type, intl } = this.props; + + const dismissTitle = intl.formatMessage(messages.deleteNotification); + const dismiss = this.props.notificationId ? ( + <button + aria-label={dismissTitle} + title={dismissTitle} + onClick={this.handleNotificationDeleteClick} + className='status__prepend-dismiss-button' + > + <i className='fa fa-eraser' /> + </button> + ) : null; + + return !type ? null : ( + <aside className={type === 'reblogged_by' ? 'status__prepend' : 'notification__message'}> + <div className={type === 'reblogged_by' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> + <i + className={`fa fa-fw fa-${ + type === 'favourite' ? 'star star-icon' : 'retweet' + } status__prepend-icon`} + /> + </div> + <Message /> + {dismiss} + </aside> + ); + } + +} diff --git a/app/javascript/glitch/locales/en.json b/app/javascript/glitch/locales/en.json new file mode 100644 index 000000000..80fdc3a39 --- /dev/null +++ b/app/javascript/glitch/locales/en.json @@ -0,0 +1,32 @@ +{ + "getting_started.open_source_notice": "Glitchsoc is free open source software forked from {Mastodon}. You can contribute or report issues on GitHub at {github}.", + "layout.auto": "Auto", + "layout.current_is": "Your current layout is:", + "layout.desktop": "Desktop", + "layout.mobile": "Mobile", + "navigation_bar.app_settings": "App settings", + "onboarding.page_one.federation": "{domain} is an 'instance' of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.", + "onboarding.page_one.welcome": "Welcome to {domain}!", + "onboarding.page_six.github": "{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}, and is compatible with any Mastodon instance or app. Glitchsoc is entirely free and open-source. You can report bugs, request features, or contribute to the code on {github}.", + "settings.auto_collapse": "Automatic collapsing", + "settings.auto_collapse_all": "Everything", + "settings.auto_collapse_lengthy": "Lengthy toots", + "settings.auto_collapse_media": "Toots with media", + "settings.auto_collapse_notifications": "Notifications", + "settings.auto_collapse_replies": "Replies", + "settings.close": "Close", + "settings.collapsed_statuses": "Collapsed toots", + "settings.enable_collapsed": "Enable collapsed toots", + "settings.general": "General", + "settings.image_backgrounds": "Image backgrounds", + "settings.image_backgrounds_media": "Preview collapsed toot media", + "settings.image_backgrounds_users": "Give collapsed toots an image background", + "settings.media": "Media", + "settings.media_letterbox": "Letterbox media", + "settings.media_fullwidth": "Full-width media previews", + "settings.preferences": "User preferences", + "settings.wide_view": "Wide view (Desktop mode only)", + "status.collapse": "Collapse", + "status.uncollapse": "Uncollapse", + "status.dismiss_notification": "Dismiss notification" +} diff --git a/app/javascript/glitch/reducers/local_settings.js b/app/javascript/glitch/reducers/local_settings.js new file mode 100644 index 000000000..35a8e065b --- /dev/null +++ b/app/javascript/glitch/reducers/local_settings.js @@ -0,0 +1,123 @@ +/* + +`reducers/local_settings` +======================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides our Redux reducers related to local settings. The +associated actions are: + + - __`STORE_HYDRATE` :__ + Used to hydrate the store with its initial values. + + - __`LOCAL_SETTING_CHANGE` :__ + Used to change the value of a local setting in the store. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Imports: +-------- + +*/ + +// Package imports // +import { Map as ImmutableMap } from 'immutable'; + +// Mastodon imports // +import { STORE_HYDRATE } from '../../mastodon/actions/store'; + +// Our imports // +import { LOCAL_SETTING_CHANGE } from '../actions/local_settings'; + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +initialState: +------------- + +You can see the default values for all of our local settings here. +These are only used if no previously-saved values exist. + +*/ + +const initialState = ImmutableMap({ + layout : 'auto', + stretch : true, + collapsed : ImmutableMap({ + enabled : true, + auto : ImmutableMap({ + all : false, + notifications : true, + lengthy : true, + replies : false, + media : false, + }), + backgrounds : ImmutableMap({ + user_backgrounds : false, + preview_images : false, + }), + }), + media : ImmutableMap({ + letterbox : true, + fullwidth : true, + }), +}); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +Helper functions: +----------------- + +### `hydrate(state, localSettings)` + +`hydrate()` is used to hydrate the `local_settings` part of our store +with its initial values. The `state` will probably just be the +`initialState`, and the `localSettings` should be whatever we pulled +from `localStorage`. + +*/ + +const hydrate = (state, localSettings) => state.mergeDeep(localSettings); + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/* + +`localSettings(state = initialState, action)`: +---------------------------------------------- + +This function holds our actual reducer. + +If our action is `STORE_HYDRATE`, then we call `hydrate()` with the +`local_settings` property of the provided `action.state`. + +If our action is `LOCAL_SETTING_CHANGE`, then we set `action.key` in +our state to the provided `action.value`. Note that `action.key` MUST +be an array, since we use `setIn()`. + +> __Note :__ +> We call this function `localSettings`, but its associated object +> in the store is `local_settings`. + +*/ + +export default function localSettings(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: + return hydrate(state, action.state.get('local_settings')); + case LOCAL_SETTING_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/glitch/util/bio_metadata.js b/app/javascript/glitch/util/bio_metadata.js new file mode 100644 index 000000000..0c8195e9d --- /dev/null +++ b/app/javascript/glitch/util/bio_metadata.js @@ -0,0 +1,410 @@ +/* + +`util/bio_metadata` +=================== + +> For more information on the contents of this file, please contact: +> +> - kibigo! [@kibi@glitch.social] + +This file provides two functions for dealing with bio metadata. The +functions are: + + - __`processBio(content)` :__ + Processes `content` to extract any frontmatter. The returned + object has two properties: `text`, which contains the text of + `content` sans-frontmatter, and `metadata`, which is an array + of key-value pairs (in two-element array format). If no + frontmatter was provided in `content`, then `metadata` will be + an empty array. + + - __`createBio(note, data)` :__ + Reverses the process in `processBio()`; takes a `note` and an + array of two-element arrays (which should give keys and values) + and outputs a string containing a well-formed bio with + frontmatter. + +*/ + +// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + +/*********************************************************************\ + + To my lovely code maintainers, + + The syntax recognized by the Mastodon frontend for its bio metadata + feature is a subset of that provided by the YAML 1.2 specification. + In particular, Mastodon recognizes metadata which is provided as an + implicit YAML map, where each key-value pair takes up only a single + line (no multi-line values are permitted). To simplify the level of + processing required, Mastodon metadata frontmatter has been limited + to only allow those characters in the `c-printable` set, as defined + by the YAML 1.2 specification, instead of permitting those from the + `nb-json` characters inside double-quoted strings like YAML proper. + ¶ It is important to note that Mastodon only borrows the *syntax* + of YAML, not its semantics. This is to say, Mastodon won't make any + attempt to interpret the data it receives. `true` will not become a + boolean; `56` will not be interpreted as a number. Rather, each key + and every value will be read as a string, and as a string they will + remain. The order of the pairs is unchanged, and any duplicate keys + are preserved. However, YAML escape sequences will be replaced with + the proper interpretations according to the YAML 1.2 specification. + ¶ The implementation provided below interprets `<br>` as `\n` and + allows for an open <p> tag at the beginning of the bio. It replaces + the escaped character entities `'` and `"` with single or + double quotes, respectively, prior to processing. However, no other + escaped characters are replaced, not even those which might have an + impact on the syntax otherwise. These minor allowances are provided + because the Mastodon backend will insert these things automatically + into a bio before sending it through the API, so it is important we + account for them. Aside from this, the YAML frontmatter must be the + very first thing in the bio, leading with three consecutive hyphen- + minues (`---`), and ending with the same or, alternatively, instead + with three periods (`...`). No limits have been set with respect to + the number of characters permitted in the frontmatter, although one + should note that only limited space is provided for them in the UI. + ¶ The regular expression used to check the existence of, and then + process, the YAML frontmatter has been split into a number of small + components in the code below, in the vain hope that it will be much + easier to read and to maintain. I leave it to the future readers of + this code to determine the extent of my successes in this endeavor. + + Sending love + warmth eternal, + - kibigo [@kibi@glitch.social] + +\*********************************************************************/ + +/* CONVENIENCE FUNCTIONS */ + +const unirex = str => new RegExp(str, 'u'); +const rexstr = exp => '(?:' + exp.source + ')'; + +/* CHARACTER CLASSES */ + +const DOCUMENT_START = /^/; +const DOCUMENT_END = /$/; +const ALLOWED_CHAR = // `c-printable` in the YAML 1.2 spec. + /[\t\n\r\x20-\x7e\x85\xa0-\ud7ff\ue000-\ufffd\u{10000}-\u{10FFFF}]/u; +const WHITE_SPACE = /[ \t]/; +const INDENTATION = / */; // Indentation must be only spaces. +const LINE_BREAK = /\r?\n|\r|<br\s*\/?>/; +const ESCAPE_CHAR = /[0abt\tnvfre "\/\\N_LP]/; +const HEXADECIMAL_CHARS = /[0-9a-fA-F]/; +const INDICATOR = /[-?:,[\]{}&#*!|>'"%@`]/; +const FLOW_CHAR = /[,[\]{}]/; + +/* NEGATED CHARACTER CLASSES */ + +const NOT_WHITE_SPACE = unirex('(?!' + rexstr(WHITE_SPACE) + ')[^]'); +const NOT_LINE_BREAK = unirex('(?!' + rexstr(LINE_BREAK) + ')[^]'); +const NOT_INDICATOR = unirex('(?!' + rexstr(INDICATOR) + ')[^]'); +const NOT_FLOW_CHAR = unirex('(?!' + rexstr(FLOW_CHAR) + ')[^]'); +const NOT_ALLOWED_CHAR = unirex( + '(?!' + rexstr(ALLOWED_CHAR) + ')[^]' +); + +/* BASIC CONSTRUCTS */ + +const ANY_WHITE_SPACE = unirex(rexstr(WHITE_SPACE) + '*'); +const ANY_ALLOWED_CHARS = unirex(rexstr(ALLOWED_CHAR) + '*'); +const NEW_LINE = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) +); +const SOME_NEW_LINES = unirex( + '(?:' + rexstr(ANY_WHITE_SPACE) + rexstr(LINE_BREAK) + ')+' +); +const POSSIBLE_STARTS = unirex( + rexstr(DOCUMENT_START) + rexstr(/<p[^<>]*>/) + '?' +); +const POSSIBLE_ENDS = unirex( + rexstr(SOME_NEW_LINES) + '|' + + rexstr(DOCUMENT_END) + '|' + + rexstr(/<\/p>/) +); +const CHARACTER_ESCAPE = unirex( + rexstr(/\\/) + + '(?:' + + rexstr(ESCAPE_CHAR) + '|' + + rexstr(/x/) + rexstr(HEXADECIMAL_CHARS) + '{2}' + '|' + + rexstr(/u/) + rexstr(HEXADECIMAL_CHARS) + '{4}' + '|' + + rexstr(/U/) + rexstr(HEXADECIMAL_CHARS) + '{8}' + + ')' +); +const ESCAPED_CHAR = unirex( + rexstr(/(?!["\\])/) + rexstr(NOT_LINE_BREAK) + '|' + + rexstr(CHARACTER_ESCAPE) +); +const ANY_ESCAPED_CHARS = unirex( + rexstr(ESCAPED_CHAR) + '*' +); +const ESCAPED_APOS = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + rexstr(/[^']|''/) +); +const ANY_ESCAPED_APOS = unirex( + rexstr(ESCAPED_APOS) + '*' +); +const FIRST_KEY_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' +); +const FIRST_VALUE_CHAR = unirex( + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + rexstr(NOT_INDICATOR) + '|' + + rexstr(/[?:-]/) + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + // Flow indicators are allowed in values. +); +const LATER_KEY_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + '(?=' + rexstr(NOT_FLOW_CHAR) + ')' + + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); +const LATER_VALUE_CHAR = unirex( + rexstr(WHITE_SPACE) + '|' + + '(?=' + rexstr(NOT_LINE_BREAK) + ')' + + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' + + // Flow indicators are allowed in values. + rexstr(/[^:#]#?/) + '|' + + rexstr(/:/) + '(?=' + rexstr(NOT_WHITE_SPACE) + ')' +); + +/* YAML CONSTRUCTS */ + +const YAML_START = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/---/) +); +const YAML_END = unirex( + rexstr(ANY_WHITE_SPACE) + rexstr(/(?:---|\.\.\.)/) +); +const YAML_LOOKAHEAD = unirex( + '(?=' + + rexstr(YAML_START) + + rexstr(ANY_ALLOWED_CHARS) + rexstr(NEW_LINE) + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) + + ')' +); +const YAML_DOUBLE_QUOTE = unirex( + rexstr(/"/) + rexstr(ANY_ESCAPED_CHARS) + rexstr(/"/) +); +const YAML_SINGLE_QUOTE = unirex( + rexstr(/'/) + rexstr(ANY_ESCAPED_APOS) + rexstr(/'/) +); +const YAML_SIMPLE_KEY = unirex( + rexstr(FIRST_KEY_CHAR) + rexstr(LATER_KEY_CHAR) + '*' +); +const YAML_SIMPLE_VALUE = unirex( + rexstr(FIRST_VALUE_CHAR) + rexstr(LATER_VALUE_CHAR) + '*' +); +const YAML_KEY = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_KEY) +); +const YAML_VALUE = unirex( + rexstr(YAML_DOUBLE_QUOTE) + '|' + + rexstr(YAML_SINGLE_QUOTE) + '|' + + rexstr(YAML_SIMPLE_VALUE) +); +const YAML_SEPARATOR = unirex( + rexstr(ANY_WHITE_SPACE) + + ':' + rexstr(WHITE_SPACE) + + rexstr(ANY_WHITE_SPACE) +); +const YAML_LINE = unirex( + '(' + rexstr(YAML_KEY) + ')' + + rexstr(YAML_SEPARATOR) + + '(' + rexstr(YAML_VALUE) + ')' +); + +/* FRONTMATTER REGEX */ + +const YAML_FRONTMATTER = unirex( + rexstr(POSSIBLE_STARTS) + + rexstr(YAML_LOOKAHEAD) + + rexstr(YAML_START) + rexstr(SOME_NEW_LINES) + + '(?:' + + '(' + rexstr(INDENTATION) + ')' + + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '(?:' + + '\\1' + rexstr(YAML_LINE) + rexstr(SOME_NEW_LINES) + + '){0,4}' + + ')?' + + rexstr(YAML_END) + rexstr(POSSIBLE_ENDS) +); + +/* SEARCHES */ + +const FIND_YAML_LINES = unirex( + rexstr(NEW_LINE) + rexstr(INDENTATION) + rexstr(YAML_LINE) +); + +/* STRING PROCESSING */ + +function processString(str) { + switch (str.charAt(0)) { + case '"': + return str + .substring(1, str.length - 1) + .replace(/\\0/g, '\x00') + .replace(/\\a/g, '\x07') + .replace(/\\b/g, '\x08') + .replace(/\\t/g, '\x09') + .replace(/\\\x09/g, '\x09') + .replace(/\\n/g, '\x0a') + .replace(/\\v/g, '\x0b') + .replace(/\\f/g, '\x0c') + .replace(/\\r/g, '\x0d') + .replace(/\\e/g, '\x1b') + .replace(/\\ /g, '\x20') + .replace(/\\"/g, '\x22') + .replace(/\\\//g, '\x2f') + .replace(/\\\\/g, '\x5c') + .replace(/\\N/g, '\x85') + .replace(/\\_/g, '\xa0') + .replace(/\\L/g, '\u2028') + .replace(/\\P/g, '\u2029') + .replace( + new RegExp( + unirex( + rexstr(/\\x/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{2})' + ), 'gu' + ), (_, n) => String.fromCodePoint('0x' + n) + ) + .replace( + new RegExp( + unirex( + rexstr(/\\u/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{4})' + ), 'gu' + ), (_, n) => String.fromCodePoint('0x' + n) + ) + .replace( + new RegExp( + unirex( + rexstr(/\\U/) + '(' + rexstr(HEXADECIMAL_CHARS) + '{8})' + ), 'gu' + ), (_, n) => String.fromCodePoint('0x' + n) + ); + case '\'': + return str + .substring(1, str.length - 1) + .replace(/''/g, '\''); + default: + return str; + } +} + +/* BIO PROCESSING */ + +export function processBio(content) { + content = content.replace(/"/g, '"').replace(/'/g, '\''); + let result = { + text: content, + metadata: [], + }; + let yaml = content.match(YAML_FRONTMATTER); + if (!yaml) return result; + else yaml = yaml[0]; + let start = content.search(YAML_START); + let end = start + yaml.length - yaml.search(YAML_START); + result.text = content.substr(0, start) + content.substr(end); + let metadata = null; + let query = new RegExp(FIND_YAML_LINES, 'g'); + while ((metadata = query.exec(yaml))) { + result.metadata.push([ + processString(metadata[1]), + processString(metadata[2]), + ]); + } + return result; +} + +/* BIO CREATION */ + +export function createBio(note, data) { + if (!note) note = ''; + let frontmatter = ''; + if ((data && data.length) || note.match(/^\s*---\s+/)) { + if (!data) frontmatter = '---\n...\n'; + else { + frontmatter += '---\n'; + for (let i = 0; i < data.length; i++) { + let key = '' + data[i][0]; + let val = '' + data[i][1]; + + // Key processing + if (key === (key.match(YAML_SIMPLE_KEY) || [])[0]) /* do nothing */; + else if (key.indexOf('\'') === -1 && key === (key.match(ANY_ESCAPED_APOS) || [])[0]) key = '\'' + key + '\''; + else { + key = key + .replace(/\x00/g, '\\0') + .replace(/\x07/g, '\\a') + .replace(/\x08/g, '\\b') + .replace(/\x0a/g, '\\n') + .replace(/\x0b/g, '\\v') + .replace(/\x0c/g, '\\f') + .replace(/\x0d/g, '\\r') + .replace(/\x1b/g, '\\e') + .replace(/\x22/g, '\\"') + .replace(/\x5c/g, '\\\\'); + let badchars = key.match( + new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu') + ) || []; + for (let j = 0; j < badchars.length; j++) { + key = key.replace( + badchars[i], + '\\u' + badchars[i].codePointAt(0).toLocaleString('en', { + useGrouping: false, + minimumIntegerDigits: 4, + }) + ); + } + key = '"' + key + '"'; + } + + // Value processing + if (val === (val.match(YAML_SIMPLE_VALUE) || [])[0]) /* do nothing */; + else if (val.indexOf('\'') === -1 && val === (val.match(ANY_ESCAPED_APOS) || [])[0]) val = '\'' + val + '\''; + else { + val = val + .replace(/\x00/g, '\\0') + .replace(/\x07/g, '\\a') + .replace(/\x08/g, '\\b') + .replace(/\x0a/g, '\\n') + .replace(/\x0b/g, '\\v') + .replace(/\x0c/g, '\\f') + .replace(/\x0d/g, '\\r') + .replace(/\x1b/g, '\\e') + .replace(/\x22/g, '\\"') + .replace(/\x5c/g, '\\\\'); + let badchars = val.match( + new RegExp(rexstr(NOT_ALLOWED_CHAR), 'gu') + ) || []; + for (let j = 0; j < badchars.length; j++) { + val = val.replace( + badchars[i], + '\\u' + badchars[i].codePointAt(0).toLocaleString('en', { + useGrouping: false, + minimumIntegerDigits: 4, + }) + ); + } + val = '"' + val + '"'; + } + + frontmatter += key + ': ' + val + '\n'; + } + frontmatter += '...\n'; + } + } + return frontmatter + note; +} diff --git a/app/javascript/images/mastodon-getting-started.png b/app/javascript/images/mastodon-getting-started.png index e05dd493f..8fe0df76a 100644 --- a/app/javascript/images/mastodon-getting-started.png +++ b/app/javascript/images/mastodon-getting-started.png Binary files differdiff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 9f05a53e9..2ce4e9b4e 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -22,6 +22,7 @@ export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT'; export const COMPOSE_MOUNT = 'COMPOSE_MOUNT'; export const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT'; +export const COMPOSE_ADVANCED_OPTIONS_CHANGE = 'COMPOSE_ADVANCED_OPTIONS_CHANGE'; export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; @@ -71,14 +72,16 @@ export function mentionCompose(account, router) { export function submitCompose() { return function (dispatch, getState) { - const status = getState().getIn(['compose', 'text'], ''); + let status = getState().getIn(['compose', 'text'], ''); if (!status || !status.length) { return; } dispatch(submitComposeRequest()); - + if (getState().getIn(['compose', 'advanced_options', 'do_not_federate'])) { + status = status + ' 👁️'; + } api(getState).post('/api/v1/statuses', { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), @@ -245,6 +248,13 @@ export function unmountCompose() { }; }; +export function toggleComposeAdvancedOption(option) { + return { + type: COMPOSE_ADVANCED_OPTIONS_CHANGE, + option: option, + }; +} + export function changeComposeSensitivity() { return { type: COMPOSE_SENSITIVITY_CHANGE, diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index c7d248122..b2a0f7ac3 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -6,6 +6,8 @@ import { defineMessages } from 'react-intl'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +export const NOTIFICATION_DELETE_SUCCESS = 'NOTIFICATION_DELETE_SUCCESS'; + export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST'; export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS'; export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL'; @@ -187,3 +189,18 @@ export function scrollTopNotifications(top) { top, }; }; + +export function deleteNotification(id) { + return (dispatch, getState) => { + api(getState).delete(`/api/v1/notifications/${id}`).then(() => { + dispatch(deleteNotificationSuccess(id)); + }); + }; +}; + +export function deleteNotificationSuccess(id) { + return { + type: NOTIFICATION_DELETE_SUCCESS, + id: id, + }; +}; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index ba2736d7a..589215ce8 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -9,8 +9,12 @@ export default class ColumnBackButton extends React.PureComponent { }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } } render () { diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js index 3b4f46d99..2cdf1b25b 100644 --- a/app/javascript/mastodon/components/column_back_button_slim.js +++ b/app/javascript/mastodon/components/column_back_button_slim.js @@ -9,8 +9,12 @@ export default class ColumnBackButtonSlim extends React.PureComponent { }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } } render () { diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 5b2a4d84c..e9f041be6 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -45,8 +45,12 @@ export default class ColumnHeader extends React.PureComponent { } handleBackClick = () => { - if (window.history && window.history.length === 1) this.context.router.history.push('/'); - else this.context.router.history.goBack(); + // if history is exhausted, or we would leave mastodon, just go to root. + if (window.history && (window.history.length === 1 || window.history.length === window._mastoInitialHistoryLen)) { + this.context.router.history.push('/'); + } else { + this.context.router.history.goBack(); + } } handleTransitionEnd = () => { diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index ac734f5ad..748283853 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -17,6 +17,7 @@ export default class IconButton extends React.PureComponent { disabled: PropTypes.bool, inverted: PropTypes.bool, animate: PropTypes.bool, + flip: PropTypes.bool, overlay: PropTypes.bool, }; @@ -69,7 +70,7 @@ export default class IconButton extends React.PureComponent { } return ( - <Motion defaultStyle={{ rotate: this.props.active ? -360 : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? -360 : 0, { stiffness: 120, damping: 7 }) : 0 }}> + <Motion defaultStyle={{ rotate: this.props.active ? (this.props.flip ? -180 : -360) : 0 }} style={{ rotate: this.props.animate ? spring(this.props.active ? (this.props.flip ? -180 : -360) : 0, { stiffness: this.props.flip ? 60 : 120, damping: 7 }) : 0 }}> {({ rotate }) => <button aria-label={this.props.title} diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js index 89a358e38..680a4e58a 100644 --- a/app/javascript/mastodon/components/media_gallery.js +++ b/app/javascript/mastodon/components/media_gallery.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/gallery + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 6b9fdd2af..6605457f7 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 7bb394e71..2fad0fa5a 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/action_bar + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 1b803a22e..ad925edef 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/content + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import escapeTextContentForBrowser from 'escape-html'; diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 86e8386bd..e7b38a07a 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { ScrollContainer } from 'react-router-scroll'; import PropTypes from 'prop-types'; -import StatusContainer from '../containers/status_container'; +import StatusContainer from '../../glitch/components/status/container'; import LoadMore from './load_more'; import ImmutablePureComponent from 'react-immutable-pure-component'; import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; diff --git a/app/javascript/mastodon/components/video_player.js b/app/javascript/mastodon/components/video_player.js index 999cf42d9..5f2447c6d 100644 --- a/app/javascript/mastodon/components/video_player.js +++ b/app/javascript/mastodon/components/video_player.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/player + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 87ab6023c..8287375c4 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -23,7 +23,13 @@ const { localeData, messages } = getLocale(); addLocaleData(localeData); export const store = configureStore(); -const hydrateAction = hydrateStore(JSON.parse(document.getElementById('initial-state').textContent)); +const initialState = JSON.parse(document.getElementById('initial-state').textContent); +try { + initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings')); +} catch (e) { + initialState.local_settings = {}; +} +const hydrateAction = hydrateStore(initialState); store.dispatch(hydrateAction); export default class Mastodon extends React.PureComponent { diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 438ecfe43..9b7f984e0 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/status/container + import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 3239b1085..1133e8a4e 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/account/header + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 167a2097e..09883d7d6 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -1,7 +1,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; -import InnerHeader from '../../account/components/header'; +import InnerHeader from '../../../../glitch/components/account/header'; import ActionBar from '../../account/components/action_bar'; import MissingIndicator from '../../../components/missing_indicator'; import ImmutablePureComponent from 'react-immutable-pure-component'; diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 98e823555..58064fac2 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -11,6 +11,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import Collapsable from '../../../components/collapsable'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import ComposeAdvancedOptionsContainer from '../../../../glitch/components/compose/advanced_options/container'; import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from './emoji_picker_dropdown'; import UploadFormContainer from '../containers/upload_form_container'; @@ -35,6 +36,9 @@ export default class ComposeForm extends ImmutablePureComponent { suggestions: ImmutablePropTypes.list, spoiler: PropTypes.bool, privacy: PropTypes.string, + advanced_options: ImmutablePropTypes.contains({ + do_not_federate: PropTypes.bool, + }), spoiler_text: PropTypes.string, focusDate: PropTypes.instanceOf(Date), preselectDate: PropTypes.instanceOf(Date), @@ -144,7 +148,8 @@ export default class ComposeForm extends ImmutablePureComponent { render () { const { intl, onPaste, showSearch } = this.props; const disabled = this.props.is_submitting; - const text = [this.props.spoiler_text, this.props.text].join(''); + const maybeEye = this.props.advanced_options.get('do_not_federate') ? ' 👁️' : ''; + const text = [this.props.spoiler_text, this.props.text, maybeEye].join(''); let publishText = ''; @@ -193,6 +198,7 @@ export default class ComposeForm extends ImmutablePureComponent { <div className='compose-form__buttons'> <UploadButtonContainer /> <PrivacyDropdownContainer /> + <ComposeAdvancedOptionsContainer /> <SensitiveButtonContainer /> <SpoilerButtonContainer /> </div> diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js index fa4f560f3..7983edb85 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.js +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -15,7 +15,7 @@ export default class NavigationBar extends ImmutablePureComponent { return ( <div className='navigation-bar'> <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}> - <Avatar src={this.props.account.get('avatar')} animate size={40} /> + <Avatar src={this.props.account.get('avatar')} staticSrc={this.props.account.get('avatar_static')} size={40} /> </Permalink> <div className='navigation-bar__profile'> diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js index ae4d1e86a..cae4ca412 100644 --- a/app/javascript/mastodon/features/compose/components/search_results.js +++ b/app/javascript/mastodon/features/compose/components/search_results.js @@ -2,7 +2,7 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import AccountContainer from '../../../containers/account_container'; -import StatusContainer from '../../../containers/status_container'; +import StatusContainer from '../../../../glitch/components/status/container'; import Link from 'react-router-dom/Link'; import ImmutablePureComponent from 'react-immutable-pure-component'; diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 12d435ded..1911edbf9 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -15,6 +15,7 @@ const mapStateToProps = state => ({ text: state.getIn(['compose', 'text']), suggestion_token: state.getIn(['compose', 'suggestion_token']), suggestions: state.getIn(['compose', 'suggestions']), + advanced_options: state.getIn(['compose', 'advanced_options']), spoiler: state.getIn(['compose', 'spoiler']), spoiler_text: state.getIn(['compose', 'spoiler_text']), privacy: state.getIn(['compose', 'privacy']), diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 6aa5de96c..69bead689 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -5,6 +5,8 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import { mountCompose, unmountCompose } from '../../actions/compose'; +import { openModal } from '../../actions/modal'; +import { changeLocalSetting } from '../../../glitch/actions/local_settings'; import Link from 'react-router-dom/Link'; import { injectIntl, defineMessages } from 'react-intl'; import SearchContainer from './containers/search_container'; @@ -18,7 +20,7 @@ const messages = defineMessages({ notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, }); @@ -47,6 +49,16 @@ export default class Compose extends React.PureComponent { this.props.dispatch(unmountCompose()); } + onLayoutClick = (e) => { + const layout = e.currentTarget.getAttribute('data-mastodon-layout'); + this.props.dispatch(changeLocalSetting(['layout'], layout)); + e.preventDefault(); + } + + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + render () { const { multiColumn, showSearch, intl } = this.props; @@ -69,12 +81,14 @@ export default class Compose extends React.PureComponent { {!columns.some(column => column.get('id') === 'PUBLIC') && ( <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role='img' aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link> )} - <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role='img' aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a> + <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)}><i role='img' aria-label={intl.formatMessage(messages.settings)} className='fa fa-fw fa-cogs' /></a> <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role='img' aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a> </div> ); } + + return ( <div className='drawer'> {header} @@ -95,6 +109,7 @@ export default class Compose extends React.PureComponent { } </Motion> </div> + </div> ); } diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index f8ea01024..684612b1c 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -4,6 +4,7 @@ import ColumnLink from '../ui/components/column_link'; import ColumnSubheading from '../ui/components/column_subheading'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; +import { openModal } from '../../actions/modal'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -17,6 +18,7 @@ const messages = defineMessages({ settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, @@ -39,8 +41,13 @@ export default class GettingStarted extends ImmutablePureComponent { me: ImmutablePropTypes.map.isRequired, columns: ImmutablePropTypes.list, multiColumn: PropTypes.bool, + dispatch: PropTypes.func.isRequired, }; + openSettings = () => { + this.props.dispatch(openModal('SETTINGS', {})); + } + render () { const { intl, me, columns, multiColumn } = this.props; @@ -79,27 +86,30 @@ export default class GettingStarted extends ImmutablePureComponent { return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> - <div className='getting-started__wrapper'> - <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> - {navItems} - <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> - <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> - <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> - <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> - </div> + <div className='scrollable optionally-scrollable'> + <div className='getting-started__wrapper'> + <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> + {navItems} + <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> + <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> + <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> + <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} /> + <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' /> + </div> - <div className='getting-started__footer scrollable optionally-scrollable'> - <div className='static-content getting-started'> - <p> - <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> - </p> - <p> - <FormattedMessage - id='getting_started.open_source_notice' - defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' - values={{ github: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>tootsuite/mastodon</a> }} - /> - </p> + <div className='getting-started__footer'> + <div className='static-content getting-started'> + <p> + <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> + </p> + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' + values={{ github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} + /> + </p> + </div> </div> </div> </Column> diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js index 9d631644a..0771849c2 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.js +++ b/app/javascript/mastodon/features/notifications/components/notification.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/notification + import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import StatusContainer from '../../../containers/status_container'; diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js index 786222967..1f98a66d2 100644 --- a/app/javascript/mastodon/features/notifications/containers/notification_container.js +++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js @@ -1,3 +1,6 @@ +// THIS FILE EXISTS FOR UPSTREAM COMPATIBILITY & SHOULDN'T BE USED !! +// SEE INSTEAD : glitch/components/notification/container + import { connect } from 'react-redux'; import { makeGetNotification } from '../../../selectors'; import Notification from '../components/notification'; diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index c5853d3ba..39fb4b26d 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -6,7 +6,7 @@ import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import NotificationContainer from './containers/notification_container'; +import NotificationContainer from '../../../glitch/components/notification/container'; import { ScrollContainer } from 'react-router-scroll'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 619957dbe..67d1e822d 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from '../../../components/avatar'; import DisplayName from '../../../components/display_name'; -import StatusContent from '../../../components/status_content'; -import MediaGallery from '../../../components/media_gallery'; -import VideoPlayer from '../../../components/video_player'; +import StatusContent from '../../../../glitch/components/status/content'; +import StatusGallery from '../../../../glitch/components/status/gallery'; +import StatusPlayer from '../../../../glitch/components/status/player'; import AttachmentList from '../../../components/attachment_list'; import Link from 'react-router-dom/Link'; import { FormattedDate, FormattedNumber } from 'react-intl'; @@ -20,6 +20,7 @@ export default class DetailedStatus extends ImmutablePureComponent { static propTypes = { status: ImmutablePropTypes.map.isRequired, + settings: ImmutablePropTypes.map.isRequired, onOpenMedia: PropTypes.func.isRequired, onOpenVideo: PropTypes.func.isRequired, autoPlayGif: PropTypes.bool, @@ -36,21 +37,41 @@ export default class DetailedStatus extends ImmutablePureComponent { render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; + const { settings } = this.props; let media = ''; + let mediaIcon = null; let applicationLink = ''; if (status.get('media_attachments').size > 0) { 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') { - media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />; + media = ( + <StatusPlayer + sensitive={status.get('sensitive')} + media={status.getIn(['media_attachments', 0])} + letterbox={settings.getIn(['media', 'letterbox'])} + height={250} + onOpenVideo={this.props.onOpenVideo} + autoplay + /> + ); + mediaIcon = 'video-camera'; } else { - media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />; + media = ( + <StatusGallery + sensitive={status.get('sensitive')} + media={status.get('media_attachments')} + letterbox={settings.getIn(['media', 'letterbox'])} + 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>; @@ -63,9 +84,11 @@ export default class DetailedStatus extends ImmutablePureComponent { <DisplayName account={status.get('account')} /> </a> - <StatusContent status={status} /> - - {media} + <StatusContent + status={status} + media={media} + mediaIcon={mediaIcon} + /> <div className='detailed-status__meta'> <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'> diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index cbabdd5bc..d774dfdfe 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -22,7 +22,7 @@ import { initReport } from '../../actions/reports'; import { makeGetStatus } from '../../selectors'; import { ScrollContainer } from 'react-router-scroll'; 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'; @@ -37,6 +37,7 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ status: getStatus(state, Number(props.params.statusId)), + settings: state.get('local_settings'), ancestorsIds: state.getIn(['contexts', 'ancestors', Number(props.params.statusId)]), descendantsIds: state.getIn(['contexts', 'descendants', Number(props.params.statusId)]), me: state.getIn(['meta', 'me']), @@ -60,6 +61,7 @@ export default class Status extends ImmutablePureComponent { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, status: ImmutablePropTypes.map, + settings: ImmutablePropTypes.map.isRequired, ancestorsIds: ImmutablePropTypes.list, descendantsIds: ImmutablePropTypes.list, me: PropTypes.number, @@ -143,7 +145,7 @@ export default class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; + const { status, settings, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props; if (status === null) { return ( @@ -172,6 +174,7 @@ export default class Status extends ImmutablePureComponent { <DetailedStatus status={status} + settings={settings} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index 6c80a1084..a1b0cf4bd 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Button from '../../../components/button'; -import StatusContent from '../../../components/status_content'; +import StatusContent from '../../../../glitch/components/status/content'; import Avatar from '../../../components/avatar'; import RelativeTimestamp from '../../../components/relative_timestamp'; import DisplayName from '../../../components/display_name'; diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js index cbdb6534f..cbc926581 100644 --- a/app/javascript/mastodon/features/ui/components/column_link.js +++ b/app/javascript/mastodon/features/ui/components/column_link.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Link from 'react-router-dom/Link'; -const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { +const ColumnLink = ({ icon, text, to, onClick, href, method, hideOnMobile }) => { if (href) { return ( <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> @@ -10,13 +10,20 @@ const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => { {text} </a> ); - } else { + } else if (to) { return ( <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}> <i className={`fa fa-fw fa-${icon} column-link__icon`} /> {text} </Link> ); + } else { + return ( + <a onClick={onClick} role='button' tabIndex='0' className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}> + <i className={`fa fa-fw fa-${icon} column-link__icon`} /> + {text} + </a> + ); } }; @@ -24,6 +31,7 @@ ColumnLink.propTypes = { icon: PropTypes.string.isRequired, text: PropTypes.string.isRequired, to: PropTypes.string, + onClick: PropTypes.func, href: PropTypes.string, method: PropTypes.string, hideOnMobile: PropTypes.bool, diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 4240871a7..84461d9b5 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -12,6 +12,7 @@ import { BoostModal, ConfirmationModal, ReportModal, + SettingsModal, } from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -21,6 +22,7 @@ const MODAL_COMPONENTS = { 'BOOST': BoostModal, 'CONFIRM': ConfirmationModal, 'REPORT': ReportModal, + 'SETTINGS': SettingsModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js index 3d59785e2..1b1cb00da 100644 --- a/app/javascript/mastodon/features/ui/components/onboarding_modal.js +++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js @@ -28,8 +28,8 @@ const PageOne = ({ acct, domain }) => ( </div> <div> - <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1> - <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p> + <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to {domain}!' values={{ domain }} /></h1> + <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></p> <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }} /></p> </div> </div> @@ -148,8 +148,8 @@ const PageSix = ({ admin, domain }) => { <div className='onboarding-modal__page onboarding-modal__page-six'> <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1> {adminSection} - <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> - <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p> + <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ domain, apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p> <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p> </div> ); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 3baf09b93..5a0398eb4 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -39,10 +39,12 @@ import { // 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 mapStateToProps = state => ({ systemFontUi: state.getIn(['meta', 'system_font_ui']), + layout: state.getIn(['local_settings', 'layout']), + isWide: state.getIn(['local_settings', 'stretch']), }); @connect(mapStateToProps) @@ -51,6 +53,8 @@ export default class UI extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, children: PropTypes.node, + layout: PropTypes.string, + isWide: PropTypes.bool, systemFontUi: PropTypes.bool, }; @@ -148,16 +152,28 @@ export default class UI extends React.PureComponent { render () { const { width, draggingOver } = this.state; - const { children } = this.props; - - const className = classNames('ui', { + const { children, layout, isWide } = this.props; + + const columnsClass = layout => { + switch (layout) { + case 'single': + return 'single-column'; + case 'multiple': + return 'multi-columns'; + default: + return 'auto-columns'; + } + }; + + const className = classNames('ui', columnsClass(layout), { + 'wide': isWide, 'system-font': this.props.systemFontUi, }); return ( <div className={className} ref={this.setRef}> <TabsBar /> - <ColumnsAreaContainer singleColumn={isMobile(width)}> + <ColumnsAreaContainer singleColumn={isMobile(width, layout)}> <WrappedSwitch> <Redirect from='/' to='/getting-started' exact /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 55de114b5..01e31749b 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -102,6 +102,13 @@ export function ReportModal () { return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal'); } +export function SettingsModal () { + return import(/* webpackChunkName: "modals/settings_modal" */'../../../../glitch/components/settings/container'); +} + +// THESE AREN'T USED BY US; SEE `glitch/components/status` AND `mastodon/features/status`. // +// IF MASTODON EVER CHANGES DETAILED STATUSES TO REQUIRE THEM, WE'LL NEED TO UPDATE THE URLS OR SOMETHING LOL. // + export function MediaGallery () { return import(/* webpackChunkName: "status/MediaGallery" */'../../../components/media_gallery'); } diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js index 992e63727..014a9a8d5 100644 --- a/app/javascript/mastodon/is_mobile.js +++ b/app/javascript/mastodon/is_mobile.js @@ -1,7 +1,14 @@ const LAYOUT_BREAKPOINT = 1024; -export function isMobile(width) { - return width <= LAYOUT_BREAKPOINT; +export function isMobile(width, columns) { + switch (columns) { + case 'multiple': + return false; + case 'single': + return true; + default: + return width <= LAYOUT_BREAKPOINT; + } }; const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index aaa558c0e..5c4daa8e7 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -1225,4 +1225,4 @@ ], "path": "app/javascript/mastodon/features/ui/components/video_modal.json" } -] \ No newline at end of file +] diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index cc422c109..80a169f51 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -30,6 +30,11 @@ function main() { WebPushSubscription.register(); } perf.stop('main()'); + + // remember the initial URL + if (window.history && typeof window._mastoInitialHistoryLen === 'undefined') { + window._mastoInitialHistoryLen = window.history.length; + } }); } diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 781e6e11b..4dce634a4 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -16,6 +16,7 @@ import { COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, + COMPOSE_ADVANCED_OPTIONS_CHANGE, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, @@ -29,6 +30,9 @@ import uuid from '../uuid'; const initialState = ImmutableMap({ mounted: false, + advanced_options: ImmutableMap({ + do_not_federate: false, + }), sensitive: false, spoiler: false, spoiler_text: '', @@ -44,6 +48,9 @@ const initialState = ImmutableMap({ 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)), @@ -68,6 +75,7 @@ function clearAll(state) { map.set('spoiler_text', ''); map.set('is_submitting', false); map.set('in_reply_to', null); + map.set('advanced_options', state.get('default_advanced_options')); map.set('privacy', state.get('default_privacy')); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); @@ -147,6 +155,11 @@ export default function compose(state = initialState, action) { return state.set('mounted', true); case COMPOSE_UNMOUNT: return state.set('mounted', false); + case COMPOSE_ADVANCED_OPTIONS_CHANGE: + return state + .set('advanced_options', + state.get('advanced_options').set(action.option, !state.getIn(['advanced_options', action.option]))) + .set('idempotencyKey', uuid()); case COMPOSE_SENSITIVITY_CHANGE: return state .set('sensitive', !state.get('sensitive')) @@ -174,6 +187,9 @@ export default function compose(state = initialState, action) { map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('advanced_options', new ImmutableMap({ + do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')), + })); map.set('focusDate', new Date()); map.set('preselectDate', new Date()); map.set('idempotencyKey', uuid()); @@ -193,6 +209,7 @@ export default function compose(state = initialState, action) { map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('advanced_options', state.get('default_advanced_options')); map.set('idempotencyKey', uuid()); }); case COMPOSE_SUBMIT_REQUEST: diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 3aaf259c2..86cda2adc 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters'; import statuses from './statuses'; import relationships from './relationships'; import settings from './settings'; +import local_settings from '../../glitch/reducers/local_settings'; import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; @@ -33,6 +34,7 @@ const reducers = { statuses, relationships, settings, + local_settings, push_notifications, cards, reports, diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index 0063d24e4..da5fcde84 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -8,6 +8,7 @@ import { NOTIFICATIONS_EXPAND_FAIL, NOTIFICATIONS_CLEAR, NOTIFICATIONS_SCROLL_TOP, + NOTIFICATION_DELETE_SUCCESS, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS } from '../actions/accounts'; import { TIMELINE_DELETE } from '../actions/timelines'; @@ -92,6 +93,10 @@ const deleteByStatus = (state, statusId) => { return state.update('items', list => list.filterNot(item => item.get('status') === statusId)); }; +const deleteById = (state, notificationId) => { + return state.update('items', list => list.filterNot(item => item.get('id') === notificationId)); +}; + export default function notifications(state = initialState, action) { switch(action.type) { case NOTIFICATIONS_REFRESH_REQUEST: @@ -113,6 +118,8 @@ export default function notifications(state = initialState, action) { return state.set('items', ImmutableList()).set('next', null); case TIMELINE_DELETE: return deleteByStatus(state, action.id); + case NOTIFICATION_DELETE_SUCCESS: + return deleteById(state, action.id); default: return state; } diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index dd2d76ec0..1bdee7356 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -6,6 +6,7 @@ import uuid from '../uuid'; const initialState = ImmutableMap({ onboarded: false, + layout: 'auto', home: ImmutableMap({ shows: ImmutableMap({ diff --git a/app/javascript/packs/custom.js b/app/javascript/packs/custom.js new file mode 100644 index 000000000..4db2964f6 --- /dev/null +++ b/app/javascript/packs/custom.js @@ -0,0 +1 @@ +require('../styles/custom.scss'); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 371e0f445..39daef761 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -4,6 +4,7 @@ import { delegate } from 'rails-ujs'; import emojify from '../mastodon/emoji'; import { getLocale } from '../mastodon/locales'; import loadPolyfills from '../mastodon/load_polyfills'; +import { processBio } from '../glitch/util/bio_metadata'; import ready from '../mastodon/ready'; const { localeData } = getLocale(); @@ -86,7 +87,8 @@ function main() { delegate(document, '.account_note', 'input', ({ target }) => { const noteCounter = document.querySelector('.note-counter'); if (noteCounter) { - noteCounter.textContent = 160 - length(target.value); + const noteWithoutMetadata = processBio(target.value).text; + noteCounter.textContent = 500 - length(noteWithoutMetadata); } }); } diff --git a/app/javascript/styles/_mixins.scss b/app/javascript/styles/_mixins.scss index 67d768a6c..7412991b8 100644 --- a/app/javascript/styles/_mixins.scss +++ b/app/javascript/styles/_mixins.scss @@ -1,5 +1,5 @@ @mixin avatar-radius() { - border-radius: 4px; + border-radius: $ui-avatar-border-size; background: transparent no-repeat; background-position: 50%; background-clip: padding-box; @@ -10,3 +10,33 @@ height: $size; background-size: $size $size; } + +@mixin single-column($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .single-column #{$parent} { + @content; + } +} + +@mixin limited-single-column($media, $parent: '&') { + .auto-columns #{$parent}, .single-column #{$parent} { + @media #{$media} { + @content; + } + } +} + +@mixin multi-columns($media, $parent: '&') { + .auto-columns #{$parent} { + @media #{$media} { + @content; + } + } + .multi-columns #{$parent} { + @content; + } +} diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss index b9c018391..5716163be 100644 --- a/app/javascript/styles/about.scss +++ b/app/javascript/styles/about.scss @@ -168,16 +168,14 @@ text-align: center; .avatar { - width: 80px; - height: 80px; + @include avatar-size(80px); margin: 0 auto; margin-bottom: 15px; img { + @include avatar-radius(); + @include avatar-size(80px); display: block; - width: 80px; - height: 80px; - border-radius: 48px; } } diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss index 801817d80..95b097f41 100644 --- a/app/javascript/styles/accounts.scss +++ b/app/javascript/styles/accounts.scss @@ -1,41 +1,46 @@ .card { + display: flex; background: $ui-base-color; background-size: cover; background-position: center; - padding: 60px 0; - padding-bottom: 0; border-radius: 4px 4px 0 0; box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); overflow: hidden; - position: relative; @media screen and (max-width: 700px) { border-radius: 0; box-shadow: none; } - &::after { - background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8)); - display: block; - content: ""; - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - z-index: 1; + .details { + position: relative; + padding: 60px 0 0; + text-align: center; + flex: auto; + + &::after { + background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8)); + display: block; + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 1; + } } .name { display: block; + position: relative; font-size: 20px; line-height: 18px * 1.5; color: $primary-text-color; font-weight: 500; text-align: center; - position: relative; - z-index: 2; text-shadow: 0 0 2px $base-shadow-color; + z-index: 2; small { display: block; @@ -46,17 +51,16 @@ } .avatar { - width: 120px; + position: relative; + @include avatar-size(120px); margin: 0 auto; margin-bottom: 15px; - position: relative; z-index: 2; img { - width: 120px; - height: 120px; + @include avatar-radius(); + @include avatar-size(120px); display: block; - border-radius: 120px; } } @@ -67,57 +71,36 @@ z-index: 2; } - .details { - display: flex; - margin-top: 30px; - position: relative; - z-index: 2; - flex-direction: row; - } - .details-counters { - display: flex; + display: inline-flex; + position: relative; flex-direction: row; - order: 0; + margin: 15px 0; + z-index: 2; } .counter { width: 80px; color: $ui-primary-color; padding: 5px 10px 0; - margin-bottom: 10px; - border-right: 1px solid $ui-primary-color; cursor: default; position: relative; - a { - display: block; + & + .counter { + border-left: 1px solid $ui-primary-color; } - &::after { - display: block; - content: ""; - position: absolute; - bottom: -10px; - left: 0; - width: 100%; - border-bottom: 4px solid $ui-primary-color; - opacity: 0.5; - transition: all 0.8s ease; + & > * { + opacity: .7; + transition: opacity .3s ease; } - &.active { - &::after { - border-bottom: 4px solid $ui-highlight-color; - opacity: 1; - } + &.active > *, &:hover > * { + opacity: 1; } - &:hover { - &::after { - opacity: 1; - transition-duration: 0.2s; - } + a { + display: block; } a { @@ -141,30 +124,73 @@ } .bio { - flex: 1; + position: relative; font-size: 14px; line-height: 18px; + margin: 15px 0; padding: 5px 10px; color: $ui-secondary-color; - order: 1; + z-index: 2; } - @media screen and (max-width: 480px) { - .details { - display: block; - } + .metadata { + position: relative; + min-width: 180px; + max-width: 40%; + background: rgba($base-shadow-color, 0.8); + color: $primary-text-color; + text-align: left; + overflow-y: auto; + white-space: pre-wrap; + z-index: 3; + + .metadata-item { + border-bottom: 1px $ui-primary-color solid; + padding: 15px 10px; + font-size: 18px; + line-height: 24px; + overflow: hidden; + text-overflow: ellipsis; - .bio { - text-align: center; - margin-bottom: 20px; - } + a { + color: $ui-highlight-color; + text-decoration: none; - .counter { - flex: 1 1 auto; + &:hover { + text-decoration: underline; + } + } + + b { + display: block; + font-size: 12px; + line-height: 16px; + text-transform: uppercase; + color: $ui-primary-color; + + a { + color: $ui-primary-color; + } + } } + } +} + + + +@media screen and (max-width: 500px) { - .counter:last-child { - border-right: none; + .card { + display: block; + + .metadata { + max-width: none; + background: $base-shadow-color; + border-top: 1px $ui-primary-color solid; + + .metadata-item { + padding: 15px 20px; + } } } } @@ -283,16 +309,14 @@ } .avatar { - width: 60px; - height: 60px; + @include avatar-size(60px); float: left; margin-right: 15px; img { + @include avatar-radius(); + @include avatar-size(60px); display: block; - width: 60px; - height: 60px; - border-radius: 60px; } } @@ -359,15 +383,14 @@ } & > div { + @include avatar-size(48px); float: left; margin-right: 10px; - width: 48px; - height: 48px; } .avatar { + @include avatar-radius(); display: block; - border-radius: 4px; } .display-name { diff --git a/app/javascript/styles/boost.scss b/app/javascript/styles/boost.scss index 5eb3149ef..e44df2ea4 100644 --- a/app/javascript/styles/boost.scss +++ b/app/javascript/styles/boost.scss @@ -13,6 +13,24 @@ button.icon-button i.fa-retweet { } } +// Disabled variant +button.icon-button.disabled i.fa-retweet { + &, &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{lighten($ui-base-color, 13%)}' stroke-width='0'/></svg>"); + } +} + +// Darker disabled variant for DMs +.status-direct button.icon-button.disabled i.fa-retweet { + &, &:hover { + background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{lighten($ui-base-color, 16%)}' stroke-width='0'/></svg>"); + } +} + +// Mastodon gave us this one, but I'm not sure if it's better. - @kibi@glitch.social + +/* button.icon-button.disabled i.fa-retweet { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color(lighten($ui-base-color, 13%))}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($ui-highlight-color)}' stroke-width='0'/></svg>"); } +*/ diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 1c4c97f18..a09a33e00 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -451,13 +451,97 @@ cursor: pointer; } +// --- Extra clickable area in the status gutter --- +.ui.wide { + @mixin xtraspaces-full { + height: calc(100% + 10px); + bottom: -40px; + } + @mixin xtraspaces-short { + height: calc(100% - 35px); + bottom: 0; + } + + // Avi must go on top if the toot is too short + .status__avatar { + z-index: 10; + } + + // Base styles + .status__content--with-action > div::after { + content: ''; + display: block; + width: 64px; + position: absolute; + left: -68px; + + // more than 4 never fit on FullHD, short + @include xtraspaces-short; + } + + @media screen and (min-width: 1800px) { + // 4, very wide screen + .column:nth-child(2):nth-last-child(4) { + &, & ~ .column { + .status__content--with-action > div::after { + @include xtraspaces-full; + } + } + } + } + + // 1 or 2, always fit + .column:nth-child(2):nth-last-child(1), + .column:nth-child(2):nth-last-child(2), + .column:nth-child(2):nth-last-child(3) { + &, & ~ .column { + .status__content--with-action > div::after { + @include xtraspaces-full; + } + } + } + + @media screen and (max-width: 1440px) { + // 3, small screen + .column:nth-child(2):nth-last-child(3) { + &, & ~ .column { + .status__content--with-action > div::after { + @include xtraspaces-short; + } + } + } + } + + // Phone or iPad + @media screen and (max-width: 1060px) { + .status__content--with-action > div::after { + display: none; + } + } + + // I am very sorry +} +// --- end extra clickable spaces --- + +.status-check-box { + .status__content, + .reply-indicator__content { + color: #3a3a3a; + a { + color: #005aa9; + } + } +} + .status__content, .reply-indicator__content { + position: relative; 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; .emojione { @@ -500,19 +584,10 @@ } } - .status__content__spoiler-link { - background: lighten($ui-base-color, 30%); - - &:hover { - background: lighten($ui-base-color, 33%); - text-decoration: none; - } - } - - .status__content__text { + .status__content__spoiler { display: none; - &.status__content__text--visible { + &.status__content__spoiler--visible { display: block; } } @@ -521,15 +596,30 @@ .status__content__spoiler-link { display: inline-block; border-radius: 2px; - background: transparent; - border: 0; + background: lighten($ui-base-color, 30%); + border: none; color: lighten($ui-base-color, 8%); font-weight: 500; font-size: 11px; - padding: 0 6px; + padding: 0 5px; text-transform: uppercase; line-height: inherit; cursor: pointer; + vertical-align: bottom; + + &:hover { + background: lighten($ui-base-color, 33%); + text-decoration: none; + } + + .status__content__spoiler-icon { + display: inline-block; + margin: 0 0 0 5px; + border-left: 1px solid currentColor; + padding: 0 0 0 4px; + font-size: 16px; + vertical-align: -2px; + } } .status__prepend-icon-wrapper { @@ -537,10 +627,32 @@ position: absolute; } +.status__prepend-dismiss-button { + border: 0; + background: transparent; + position: absolute; + right: -3px; + opacity: 0; + transition: opacity 0.1s ease-in-out; + + i.fa { + color: crimson; + } + + .notification__message:hover & { + opacity: 1; + } + + .notification-follow & { + right: 6px; + } +} + .status { padding: 8px 10px; padding-left: 68px; position: relative; + height: auto; min-height: 48px; border-bottom: 1px solid lighten($ui-base-color, 8%); cursor: default; @@ -597,6 +709,41 @@ } } } + + &.collapsed { + background-position: center; + background-size: cover; + user-select: none; + + &.has-background::before { + display: block; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-image: linear-gradient(to bottom, rgba($base-shadow-color, .75), rgba($base-shadow-color, .65) 24px, rgba($base-shadow-color, .8)); + content: ""; + } + + .status__display-name:hover strong { + text-decoration: none; + } + + .status__content { + height: 20px; + overflow: hidden; + text-overflow: ellipsis; + + a:hover { + text-decoration: none; + } + } + } + + .notification__message { + margin: -10px 0 10px; + } } .notification-favourite { @@ -610,9 +757,16 @@ } .status__relative-time { + display: inline-block; + margin-left: auto; + padding-left: 18px; + width: 120px; color: lighten($ui-base-color, 26%); - float: right; font-size: 14px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .status__display-name { @@ -626,7 +780,20 @@ } .status__info { + margin: 2px 0 0; font-size: 15px; + line-height: 24px; +} + +.status__info__icons { + display: inline-block; + position: relative; + float: right; + color: lighten($ui-base-color, 26%); +} + +.status__visibility-icon { + padding-left: 6px; } .status-check-box { @@ -651,10 +818,9 @@ } .status__prepend { - margin-left: 68px; + margin: -10px 0 10px; color: lighten($ui-base-color, 26%); - padding: 8px 0; - padding-bottom: 2px; + padding: 8px 0 2px; font-size: 14px; position: relative; @@ -667,17 +833,43 @@ align-items: center; display: flex; margin-top: 10px; + margin-left: -58px; + + &::before { + display: block; + flex: 1 1 0; + max-width: 58px; + content: ""; + } } .status__action-bar-button { float: left; margin-right: 18px; + flex: 0 0 auto; } .status__action-bar-dropdown { float: left; height: 18px; width: 18px; + + // Dropdown style override for centering on the icon + .dropdown--active { + position: relative; + + .dropdown__content.dropdown__right { + left: calc(50% + 3px); + right: initial; + transform: translate(-50%, 0); + top: 22px; + } + + &::after { + right: 1px; + bottom: -2px; + } + } } .detailed-status__action-bar-dropdown { @@ -821,9 +1013,12 @@ padding: 10px; } -.account__header { +.account__header__wrapper { flex: 0 0 auto; background: lighten($ui-base-color, 4%); +} + +.account__header { text-align: center; background-size: cover; background-position: center; @@ -888,6 +1083,59 @@ } } +.account__metadata { + width: 100%; + font-size: 15px; + line-height: 20px; + overflow: hidden; + border-collapse: collapse; + + a { + text-decoration: none; + + &:hover{ + text-decoration: underline; + } + } + + tr { + border-top: 1px solid lighten($ui-base-color, 8%); + } + + th, td { + padding: 14px 20px; + vertical-align: middle; + + & > div { + max-height: 40px; + overflow-y: auto; + white-space: pre-wrap; + text-overflow: ellipsis; + } + } + + th { + color: $ui-primary-color; + background: lighten($ui-base-color, 13%); + font-variant: small-caps; + max-width: 120px; + + a { + color: $primary-text-color; + } + } + + td { + flex: auto; + color: $primary-text-color; + background: $ui-base-color; + + a { + color: $ui-highlight-color; + } + } +} + .account__action-bar { border-top: 1px solid lighten($ui-base-color, 8%); border-bottom: 1px solid lighten($ui-base-color, 8%); @@ -949,12 +1197,11 @@ } .account__header__avatar { - background-size: 90px 90px; + @include avatar-radius(); + @include avatar-size(90px); display: block; - height: 90px; margin: 0 auto 10px; overflow: hidden; - width: 90px; } .account-authorize { @@ -986,12 +1233,6 @@ strong { color: $primary-text-color; } - - &.muted { - .emojione { - opacity: 0.5; - } - } } .status__display-name, @@ -1036,10 +1277,9 @@ } .status__avatar { - height: 48px; - left: 10px; position: absolute; - top: 10px; + margin-left: -58px; + height: 48px; width: 48px; } @@ -1053,7 +1293,7 @@ color: lighten($ui-base-color, 26%); } - .status__avatar { + .status__avatar, .emojione { opacity: 0.5; } @@ -1108,6 +1348,7 @@ .display-name { display: block; + position: relative; max-width: 100%; overflow: hidden; text-overflow: ellipsis; @@ -1290,11 +1531,12 @@ justify-content: flex-start; overflow-x: auto; position: relative; + padding: 10px; } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .columns-area { - padding: 10px; + padding: 0; } .react-swipeable-view-container .columns-area { @@ -1324,6 +1566,13 @@ box-sizing: border-box; display: flex; flex-direction: column; + overflow: hidden; + + .wide & { + flex: auto; + min-width: 330px; + max-width: 400px; + } > .scrollable { background: $ui-base-color; @@ -1344,7 +1593,13 @@ box-sizing: border-box; display: flex; flex-direction: column; - overflow-y: hidden; + overflow-y: auto; + + .wide & { + flex: 1 1 200px; + min-width: 300px; + max-width: 400px; + } } .drawer__tab { @@ -1356,53 +1611,56 @@ text-align: center; font-size: 16px; border-bottom: 2px solid transparent; + outline: none; + cursor: pointer; } .column, .drawer { - flex: 1 1 100%; - overflow: hidden; @supports(display: grid) { // hack to fix Chrome <57 contain: strict; } } -@media screen and (min-width: 360px) { +@include limited-single-column('screen and (max-width: 360px)', $parent: null) { .tabs-bar { - margin: 10px; - margin-bottom: 0; + margin: 0; } .search { - margin-bottom: 10px; + margin-bottom: 0; } } -@media screen and (max-width: 1024px) { - .column, - .drawer { - width: 100%; - padding: 0; - } +:root { // Overrides .wide stylings for mobile view + @include single-column('screen and (max-width: 1024px)', $parent: null) { + .column, + .drawer { + flex: auto; + width: 100%; + min-width: 0; + max-width: none; + padding: 0; + } - .columns-area { - flex-direction: column; - } + .columns-area { + flex-direction: column; + } - .search__input, - .autosuggest-textarea__textarea { - font-size: 16px; + .search__input, + .autosuggest-textarea__textarea { + font-size: 16px; + } } } -@media screen and (min-width: 1025px) { +@include multi-columns('screen and (min-width: 1025px)', $parent: null) { .columns-area { padding: 0; } .column, .drawer { - flex: 0 0 auto; padding: 10px; padding-left: 5px; padding-right: 5px; @@ -1428,28 +1686,25 @@ .drawer__pager { box-sizing: border-box; padding: 0; - flex-grow: 1; + flex: 1 1 auto; position: relative; - overflow: hidden; - display: flex; } .drawer__inner { - position: absolute; - top: 0; - left: 0; background: lighten($ui-base-color, 13%); box-sizing: border-box; padding: 0; - display: flex; - flex-direction: column; - overflow: hidden; - overflow-y: auto; - width: 100%; + position: absolute; height: 100%; + width: 100%; &.darker { + position: absolute; + top: 0; + left: 0; background: $ui-base-color; + width: 100%; + height: 100%; } } @@ -1482,6 +1737,8 @@ background: lighten($ui-base-color, 8%); flex: 0 0 auto; overflow-y: auto; + margin: 10px; + margin-bottom: 0; } .tabs-bar__link { @@ -1509,7 +1766,7 @@ &:hover, &:focus, &:active { - @media screen and (min-width: 1025px) { + @include multi-columns('screen and (min-width: 1025px)') { background: lighten($ui-base-color, 14%); transition: all 100ms linear; } @@ -1521,7 +1778,7 @@ } } -@media screen and (min-width: 600px) { +@include limited-single-column('screen and (max-width: 600px)', $parent: null) { .tabs-bar__link { span { display: inline; @@ -1529,7 +1786,7 @@ } } -@media screen and (min-width: 1025px) { +@include multi-columns('screen and (min-width: 1025px)', $parent: null) { .tabs-bar { display: none; } @@ -1712,13 +1969,15 @@ font-size: 16px; padding: 15px; text-decoration: none; + cursor: pointer; + outline: none; &:hover { background: lighten($ui-base-color, 11%); } &.hidden-on-mobile { - @media screen and (max-width: 1024px) { + @include single-column('screen and (max-width: 1024px)') { display: none; } } @@ -1763,7 +2022,7 @@ outline: 0; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -1779,7 +2038,7 @@ padding-right: 10px + 22px; resize: none; - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { height: 100px !important; // prevent auto-resize textarea resize: vertical; } @@ -1892,7 +2151,7 @@ border-bottom-color: $ui-highlight-color; } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } @@ -2106,7 +2365,7 @@ button.icon-button.active i.fa-retweet { } &.hidden-on-mobile { - @media screen and (max-width: 1024px) { + @include single-column('screen and (max-width: 1024px)') { display: none; } } @@ -2242,6 +2501,15 @@ button.icon-button.active i.fa-retweet { position: relative; text-align: center; z-index: 100; + + .status__content > & { + margin-top: 15px; // Add margin when used bare for NSFW video player + } + + &.full-width { + margin-left: -68px; + width: calc(100% + 80px); + } } .media-spoiler__warning { @@ -2813,8 +3081,82 @@ button.icon-button.active i.fa-retweet { } } +.advanced-options-dropdown { + position: relative; +} + +.advanced-options-dropdown__dropdown { + display: none; + position: absolute; + left: 0; + top: 27px; + width: 210px; + background: $simple-background-color; + border-radius: 0 4px 4px; + z-index: 2; + overflow: hidden; +} + +.advanced-options-dropdown__option { + color: $ui-base-color; + padding: 10px; + cursor: pointer; + display: flex; + + &:hover, + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + + .advanced-options-dropdown__option__content { + color: $primary-text-color; + + strong { + color: $primary-text-color; + } + } + } + + &.active:hover { + background: lighten($ui-highlight-color, 4%); + } +} + +.advanced-options-dropdown__option__toggle { + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; +} + +.advanced-options-dropdown__option__content { + flex: 1 1 auto; + color: darken($ui-primary-color, 24%); + + strong { + font-weight: 500; + display: block; + color: $ui-base-color; + } +} + +.advanced-options-dropdown.open { + .advanced-options-dropdown__value { + background: $simple-background-color; + border-radius: 4px 4px 0 0; + box-shadow: 0 -4px 4px rgba($base-shadow-color, 0.1); + } + + .advanced-options-dropdown__dropdown { + display: block; + box-shadow: 2px 4px 6px rgba($base-shadow-color, 0.1); + } +} + + .search { position: relative; + margin-bottom: 10px; } .search__input { @@ -2847,7 +3189,7 @@ button.icon-button.active i.fa-retweet { background: lighten($ui-base-color, 4%); } - @media screen and (max-width: 600px) { + @include limited-single-column('screen and (max-width: 600px)') { font-size: 16px; } } @@ -2907,6 +3249,10 @@ button.icon-button.active i.fa-retweet { font-weight: 500; } +.search-results__section { + background: $ui-base-color; +} + .search-results__hashtag { display: block; padding: 10px; @@ -3313,6 +3659,89 @@ button.icon-button.active i.fa-retweet { margin-bottom: 20px; } +.settings-modal { + position: relative; + display: flex; + flex-direction: row; + background: $ui-secondary-color; + color: $ui-base-color; + border-radius: 8px; + height: 80vh; + width: 80vw; + max-width: 740px; + max-height: 450px; + overflow: hidden; + + label { + display: block; + } + + h1 { + font-size: 18px; + font-weight: 500; + line-height: 24px; + margin-bottom: 20px; + } + + h2 { + font-size: 15px; + font-weight: 500; + line-height: 20px; + margin-top: 20px; + margin-bottom: 10px; + } +} + +.settings-modal__navigation { + background: $primary-text-color; + color: $ui-base-color; + width: 200px; + font-size: 15px; + line-height: 20px; + overflow-y: auto; + + .settings-modal__navigation-item, .settings-modal__navigation-close { + display: block; + padding: 15px 20px; + cursor: pointer; + outline: none; + text-decoration: none; + } + + .settings-modal__navigation-item { + background: $primary-text-color; + color: inherit; + border-bottom: 1px $ui-primary-color solid; + transition: background .3s; + + &:hover { + background: $ui-secondary-color; + } + + &.active { + background: $ui-highlight-color; + color: $primary-text-color; + } + } + + .settings-modal__navigation-close { + background: $error-value-color; + color: $primary-text-color; + } +} + +.settings-modal__content { + display: block; + flex: auto; + padding: 15px 20px 15px 20px; + width: 360px; + overflow-y: auto; + + select { + margin-bottom: 5px; + } +} + .onboard-sliders { display: inline-block; max-width: 30px; @@ -3531,10 +3960,21 @@ button.icon-button.active i.fa-retweet { /* Media Gallery */ .media-gallery { box-sizing: border-box; - margin-top: 8px; + margin-top: 15px; overflow: hidden; position: relative; + background: $base-shadow-color; width: 100%; + + &.full-width { + margin-left: -68px; + width: calc(100% + 80px); + } + + .detailed-status & { + margin-left:-10px; + width: calc(100% + 22px); + } } .media-gallery__item { @@ -3547,15 +3987,19 @@ button.icon-button.active i.fa-retweet { .media-gallery__item-thumbnail { cursor: zoom-in; - display: block; text-decoration: none; + width: 100%; height: 100%; + display: flex; - &, img { width: 100%; - height: 100%; - object-fit: cover; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } } } @@ -3564,17 +4008,21 @@ button.icon-button.active i.fa-retweet { overflow: hidden; position: relative; width: 100%; + display: flex; + justify-content: center; } .media-gallery__item-gifv-thumbnail { cursor: zoom-in; height: 100%; - object-fit: cover; position: relative; - top: 50%; - transform: translateY(-50%); - width: 100%; z-index: 1; + object-fit: contain; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } } .media-gallery__item-thumbnail-label { @@ -3587,22 +4035,31 @@ button.icon-button.active i.fa-retweet { /* Status Video Player */ .status__video-player { - background: $base-overlay-background; + display: flex; + align-items: center; + background: $base-shadow-color; box-sizing: border-box; cursor: default; /* May not be needed */ - margin-top: 8px; + margin-top: 15px; overflow: hidden; position: relative; + width: 100%; + + &.full-width { + margin-left: -68px; + width: calc(100% + 80px); + } } .status__video-player-video { - height: 100%; - object-fit: cover; position: relative; - top: 50%; - transform: translateY(-50%); width: 100%; z-index: 1; + + &:not(.letterbox) { + height: 100%; + object-fit: cover; + } } .status__video-player-expand, @@ -3643,8 +4100,14 @@ button.icon-button.active i.fa-retweet { background-repeat: no-repeat; background-position: center; cursor: pointer; - margin-top: 8px; + margin-top: 15px; position: relative; + width: 100%; + + &.full-width { + margin-left: -68px; + width: calc(100% + 80px); + } } .media-spoiler-video-play-icon { diff --git a/app/javascript/styles/custom.scss b/app/javascript/styles/custom.scss new file mode 100644 index 000000000..97a981243 --- /dev/null +++ b/app/javascript/styles/custom.scss @@ -0,0 +1 @@ +@import 'application'; diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss index e89cc3f09..a9111d7c9 100644 --- a/app/javascript/styles/stream_entries.scss +++ b/app/javascript/styles/stream_entries.scss @@ -64,19 +64,16 @@ .status__avatar { position: absolute; - left: 14px; - top: 14px; - width: 48px; - height: 48px; + @include avatar-size(48px); + margin-left: -62px; & > div { - width: 48px; - height: 48px; + @include avatar-size(48px); } img { + @include avatar-radius(); display: block; - border-radius: 4px; } } @@ -164,12 +161,11 @@ } .avatar { - width: 48px; - height: 48px; + @include avatar-size(48px); img { + @include avatar-radius(); display: block; - border-radius: 4px; } } diff --git a/app/javascript/styles/variables.scss b/app/javascript/styles/variables.scss index 8362096e1..bf8c12bc0 100644 --- a/app/javascript/styles/variables.scss +++ b/app/javascript/styles/variables.scss @@ -26,3 +26,6 @@ $ui-base-color: $classic-base-color !default; // Darkest $ui-primary-color: $classic-primary-color !default; // Lighter $ui-secondary-color: $classic-secondary-color !default; // Lightest $ui-highlight-color: $classic-highlight-color !default; // Vibrant + +// Avatar border size (8% default, 100% for rounded avatars) +$ui-avatar-border-size: 8%; |