diff --git a/app/javascript/glitch/components/common/avatar/index.js b/app/javascript/glitch/components/common/avatar/index.js new file mode 100644 index 0000000000..61181c768d --- /dev/null +++ b/app/javascript/glitch/components/common/avatar/index.js @@ -0,0 +1,113 @@ +// +// ======== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/common/avatar + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// The component +// ------------- + +export default class CommonAvatar extends React.PureComponent { + + // Props and state. + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + animate: PropTypes.bool, + circular: PropTypes.bool, + className: PropTypes.string, + comrade: ImmutablePropTypes.map, + } + state = { + hovering: false, + } + + // Starts or stops animation on hover. + handleMouseEnter = () => { + if (this.props.animate) return; + this.setState({ hovering: true }); + } + handleMouseLeave = () => { + if (this.props.animate) return; + this.setState({ hovering: false }); + } + + // Renders the component. + render () { + const { + handleMouseEnter, + handleMouseLeave, + } = this; + const { + account, + animate, + circular, + className, + comrade, + ...others + } = this.props; + const { hovering } = this.state; + const computedClass = classNames('glitch', 'glitch__common__avatar', { + _circular: circular, + }, className); + + // We store the image srcs here for later. + const src = account.get('avatar'); + const staticSrc = account.get('avatar_static'); + const comradeSrc = comrade ? comrade.get('avatar') : null; + const comradeStaticSrc = comrade ? comrade.get('avatar_static') : null; + + // Avatars are a straightforward div with image(s) inside. + return comrade ? ( +
+ + +
+ ) : ( +
+ +
+ ); + } + +} diff --git a/app/javascript/glitch/components/common/avatar/style.scss b/app/javascript/glitch/components/common/avatar/style.scss new file mode 100644 index 0000000000..8a3208b091 --- /dev/null +++ b/app/javascript/glitch/components/common/avatar/style.scss @@ -0,0 +1,41 @@ +@import 'variables'; + +.glitch.glitch__common__avatar { + display: inline-block; + position: relative; + + & > img { + display: block; + position: static; + margin: 0; + border-radius: $ui-avatar-border-size; + width: 100%; + height: 100%; + + &.avatar\\comrade { + position: absolute; + right: 0; + bottom: 0; + width: 50%; + height: 50%; + } + + &.avatar\\main { + margin: 0 30% 30% 0; + width: 70%; + height: 70%; + } + } + + &._circular { + & > img { + transition: border-radius ($glitch-animation-speed * .3s); + } + + &:not(:hover) { + & > img { + border-radius: 50%; + } + } + } +} diff --git a/app/javascript/glitch/components/common/button/index.js b/app/javascript/glitch/components/common/button/index.js new file mode 100644 index 0000000000..5fd16dc9d6 --- /dev/null +++ b/app/javascript/glitch/components/common/button/index.js @@ -0,0 +1,146 @@ +// +// ======== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/common/button + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; + +// Our imports. +import CommonLink from 'glitch/components/common/link'; +import CommonIcon from 'glitch/components/common/icon'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// The component +// ------------- + +export default class CommonButton extends React.PureComponent { + + static propTypes = { + active: PropTypes.bool, + animate: PropTypes.bool, + children: PropTypes.node, + className: PropTypes.string, + disabled: PropTypes.bool, + href: PropTypes.string, + icon: PropTypes.string, + onClick: PropTypes.func, + showTitle: PropTypes.bool, + title: PropTypes.string, + } + state = { + loaded: false, + } + + // The `loaded` state property activates our animations. We wait + // until an activation change in order to prevent unsightly + // animations when the component first mounts. + componentWillReceiveProps (nextProps) { + const { active } = this.props; + + // The double "not"s here cast both arguments to booleans. + if (!nextProps.active !== !active) this.setState({ loaded: true }); + } + + handleClick = (e) => { + const { onClick } = this.props; + if (!onClick) return; + onClick(e); + e.preventDefault(); + } + + // Rendering the component. + render () { + const { handleClick } = this; + const { + active, + animate, + children, + className, + disabled, + href, + icon, + onClick, + showTitle, + title, + ...others + } = this.props; + const { loaded } = this.state; + const computedClass = classNames('glitch', 'glitch__common__button', className, { + _active: active && !href, // Links can't be active + _animated: animate && loaded, + _disabled: disabled, + _link: href, + _star: icon === 'star', + '_with-text': children || title && showTitle, + }); + let conditionalProps = {}; + + // If href is provided, we render a link. + if (href) { + if (!disabled && href) conditionalProps.href = href; + if (title && !showTitle) { + if (!children) conditionalProps.title = title; + else conditionalProps['aria-label'] = title; + } + if (onClick) { + if (!disabled) conditionalProps.onClick = handleClick; + else conditionalProps['aria-disabled'] = true; + conditionalProps.role = 'button'; + conditionalProps.tabIndex = 0; + } + return ( + + {children} + {title && showTitle ? {title} : null} + + + ); + + // Otherwise, we render a button. + } else { + if (active !== void 0) conditionalProps['aria-pressed'] = active; + if (title && !showTitle) { + if (!children) conditionalProps.title = title; + else conditionalProps['aria-label'] = title; + } + if (onClick && !disabled) { + conditionalProps.onClick = handleClick; + } + return ( + + ); + } + }; + +} diff --git a/app/javascript/glitch/components/common/button/style.scss b/app/javascript/glitch/components/common/button/style.scss new file mode 100644 index 0000000000..c494d9ebfa --- /dev/null +++ b/app/javascript/glitch/components/common/button/style.scss @@ -0,0 +1,134 @@ +@import 'variables'; + +.glitch.glitch__common__button { + display: inline-block; + border: none; + padding: 0; + color: $ui-base-lighter-color; + background: transparent; + outline: thin transparent dotted; + font-size: inherit; + text-decoration: none; + cursor: pointer; + transition: color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out; + + &._animated .button\\icon { + animation-name: glitch__common__button__deactivate; + animation-duration: .9s; + animation-timing-function: ease-in-out; + + @keyframes glitch__common__button__deactivate { + from { + transform: rotate(360deg); + } + 57% { + transform: rotate(-60deg); + } + 86% { + transform: rotate(30deg); + } + to { + transform: rotate(0deg); + } + } + } + + &._active { + .button\\icon { + color: $ui-highlight-color; + } + + &._animated .button\\icon { + animation-name: glitch__common__button__activate; + + @keyframes glitch__common__button__activate { + from { + transform: rotate(0deg); + } + 57% { + transform: rotate(420deg); // Blazin' 😎 + } + 86% { + transform: rotate(330deg); + } + to { + transform: rotate(360deg); + } + } + } + + /* + The special `._star` class is given to buttons which have a star + icon (see JS). When they are active, we give them a gold star ⭐️. + */ + &._star .button\\icon { + color: $gold-star; + } + } + + /* + For links, we consider them disabled if they don't have an `href` + attribute (see JS). + */ + &._disabled { + opacity: $glitch-disabled-opacity; + cursor: default; + } + + /* + This is confusing becuase of the names, but the `color .3 ease-out` + transition is actually used when easing *in* to a hovering/active/ + focusing state, and the default transition is used when leaving. Our + buttons are a little slower to glow than they are to fade. + */ + &:active, + &:focus, + &:hover { + color: $glitch-lighter-color; + transition: color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out; + } + + &:focus { + outline-color: currentColor; + } + + /* + Buttons with text have a number of different styling rules and an + overall different appearance. + */ + &._with-text { + display: inline-block; + border: none; + border-radius: .35em; + padding: 0 .5em; + color: $glitch-texture-color; + background: $ui-base-lighter-color; + font-size: .75em; + font-weight: inherit; + text-transform: uppercase; + line-height: 1.6; + cursor: pointer; + vertical-align: baseline; + transition: background-color ($glitch-animation-speed * .15s) ease-in, outline-color ($glitch-animation-speed * .3s) ease-in-out; + + .button\\icon { + display: inline-block; + font-size: 1.25em; + vertical-align: -.1em; + } + + & > *:not(:first-child) { + margin: 0 0 0 .4em; + border-left: 1px solid currentColor; + padding: 0 0 0 .3em; + } + + &:active, + &:hover, + &:focus { + color: $glitch-texture-color; + background: $glitch-lighter-color; + transition: background-color ($glitch-animation-speed * .3s) ease-out, outline-color ($glitch-animation-speed * .15s) ease-in-out; + } + } +} diff --git a/app/javascript/glitch/components/common/icon/index.js b/app/javascript/glitch/components/common/icon/index.js new file mode 100644 index 0000000000..f575321e39 --- /dev/null +++ b/app/javascript/glitch/components/common/icon/index.js @@ -0,0 +1,59 @@ +// +// ======== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/common/icon + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import classNames from 'classnames'; +import React from 'react'; +import PropTypes from 'prop-types'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// The component +// ------------- + +const CommonIcon = ({ + className, + name, + proportional, + title, + ...others +}) => name ? ( + + + {title ? ( + {title} + ) : null} + +) : null; + +// Props. +CommonIcon.propTypes = { + className: PropTypes.string, + name: PropTypes.string, + proportional: PropTypes.bool, + title: PropTypes.string, +}; + +// Export. +export default CommonIcon; diff --git a/app/javascript/glitch/components/common/icon/style.scss b/app/javascript/glitch/components/common/icon/style.scss new file mode 100644 index 0000000000..a2b8aad642 --- /dev/null +++ b/app/javascript/glitch/components/common/icon/style.scss @@ -0,0 +1,14 @@ +@import 'variables'; + +.glitch.glitch__common__icon { + display: inline-block; + + ._for-screenreader { + position: absolute; + margin: -1px -1px; + width: 1px; + height: 1px; + clip: rect(0, 0, 0, 0); + overflow: hidden; + } +} diff --git a/app/javascript/glitch/components/common/link/index.js b/app/javascript/glitch/components/common/link/index.js new file mode 100644 index 0000000000..a7b85f2b75 --- /dev/null +++ b/app/javascript/glitch/components/common/link/index.js @@ -0,0 +1,74 @@ +// +// ======== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/common/link + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// The component +// ------------- + +export default class CommonLink extends React.PureComponent { + + // Props. + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + destination: PropTypes.string, + history: PropTypes.object, + href: PropTypes.string, + }; + + // We only reroute the link if it is an unadorned click, we have + // access to the router, and there is somewhere to reroute it *to*. + handleClick = (e) => { + const { destination, history } = this.props; + if (!history || !destination || e.button || e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; + history.push(destination); + e.preventDefault(); + } + + // Rendering. + render () { + const { handleClick } = this; + const { children, className, destination, history, href, ...others } = this.props; + const computedClass = classNames('glitch', 'glitch__common__link', className); + const conditionalProps = {}; + if (href) { + conditionalProps.href = href; + conditionalProps.onClick = handleClick; + } else if (destination) { + conditionalProps.onClick = handleClick; + conditionalProps.role = 'link'; + conditionalProps.tabIndex = 0; + } else conditionalProps.role = 'presentation'; + + return ( + {children} + ); + } + +} diff --git a/app/javascript/glitch/components/common/link/style.scss b/app/javascript/glitch/components/common/link/style.scss new file mode 100644 index 0000000000..4f54d8f0e1 --- /dev/null +++ b/app/javascript/glitch/components/common/link/style.scss @@ -0,0 +1,11 @@ +@import 'variables'; + +/* +Most link styling happens elsewhere but we disable text-decoration +here. +*/ +.glitch.glitch__common__link { + display: inline; + color: $ui-secondary-color; + text-decoration: none; +} diff --git a/app/javascript/glitch/components/common/separator/index.js b/app/javascript/glitch/components/common/separator/index.js new file mode 100644 index 0000000000..a749abde5a --- /dev/null +++ b/app/javascript/glitch/components/common/separator/index.js @@ -0,0 +1,49 @@ +// +// ======== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/common/separator + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// The component +// ------------- + +const CommonSeparator = ({ + className, + visible, + ...others +}) => visible ? ( + // Contents provided via CSS. +) : null; + +// Props. +CommonSeparator.propTypes = { + className: PropTypes.string, + visible: PropTypes.bool, +}; + +// Export. +export default CommonSeparator; diff --git a/app/javascript/glitch/components/common/separator/style.scss b/app/javascript/glitch/components/common/separator/style.scss new file mode 100644 index 0000000000..0cf1c11a29 --- /dev/null +++ b/app/javascript/glitch/components/common/separator/style.scss @@ -0,0 +1,15 @@ +@import 'variables'; + +/* +The default contents for a separator is an interpunct, surrounded by +spaces. However, this can be changed using CSS selectors. +*/ +.glitch.glitch__common__separator { + display: inline-block; + + &::after { + display: inline-block; + padding: 0 .3em; + content: "·"; + } +} diff --git a/app/javascript/glitch/components/list/conversation/container.js b/app/javascript/glitch/components/list/conversation/container.js new file mode 100644 index 0000000000..5cc9a822d5 --- /dev/null +++ b/app/javascript/glitch/components/list/conversation/container.js @@ -0,0 +1,52 @@ +// +// ================= + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation/container + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import { connect } from 'react-redux'; + +// Mastodon imports. +import { fetchContext } from 'mastodon/actions/statuses'; + +// Our imports. +import ListConversation from '.'; + +// * * * * * * * // + +// State mapping +// ------------- + +const mapStateToProps = (state, { id }) => { + return { + ancestors : state.getIn(['contexts', 'ancestors', id]), + descendants : state.getIn(['contexts', 'descendants', id]), + }; +}; + +// * * * * * * * // + +// Dispatch mapping +// ---------------- + +const mapDispatchToProps = (dispatch) => ({ + fetch (id) { + dispatch(fetchContext(id)); + }, +}); + +// * * * * * * * // + +// Connecting +// ---------- + +export default connect(mapStateToProps, mapDispatchToProps)(ListConversation); diff --git a/app/javascript/glitch/components/list/conversation/index.js b/app/javascript/glitch/components/list/conversation/index.js new file mode 100644 index 0000000000..5935dd22d8 --- /dev/null +++ b/app/javascript/glitch/components/list/conversation/index.js @@ -0,0 +1,80 @@ +// +// ==================== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/list/conversation + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import React from 'react'; +import PropTypes from 'prop-types'; +import ScrollContainer from 'react-router-scroll'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; + +// Our imports. +import StatusContainer from 'glitch/components/status/container'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// The component +// ------------- + +export default class ListConversation extends ImmutablePureComponent { + + // Props. + static propTypes = { + id: PropTypes.number.isRequired, + ancestors: ImmutablePropTypes.list, + descendants: ImmutablePropTypes.list, + fetch: PropTypes.func.isRequired, + } + + // If this is a detailed status, we should fetch its contents and + // context upon mounting. + componentWillMount () { + const { id, fetch } = this.props; + fetch(id); + } + + // Similarly, if the component receives new props, we need to fetch + // the new status. + componentWillReceiveProps (nextProps) { + const { id, fetch } = this.props; + if (nextProps.id !== id) fetch(nextProps.id); + } + + // We just render our status inside a column with its + // ancestors and decendants. + render () { + const { id, ancestors, descendants } = this.props; + return ( + +
+ {ancestors && ancestors.size > 0 ? ( + ancestors.map( + ancestor => + ) + ) : null} + + {descendants && descendants.size > 0 ? ( + descendants.map( + descendant => + ) + ) : null} +
+
+ ); + } + +}; diff --git a/app/javascript/glitch/components/list/conversation/style.scss b/app/javascript/glitch/components/list/conversation/style.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/javascript/glitch/components/column/notif_cleaning_widget/container.js b/app/javascript/glitch/components/list/notif_cleaning_widget/container.js similarity index 100% rename from app/javascript/glitch/components/column/notif_cleaning_widget/container.js rename to app/javascript/glitch/components/list/notif_cleaning_widget/container.js diff --git a/app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js b/app/javascript/glitch/components/list/notif_cleaning_widget/notification_purge_buttons.js similarity index 100% rename from app/javascript/glitch/components/column/notif_cleaning_widget/notification_purge_buttons.js rename to app/javascript/glitch/components/list/notif_cleaning_widget/notification_purge_buttons.js diff --git a/app/javascript/glitch/components/list/statuses/index.js b/app/javascript/glitch/components/list/statuses/index.js new file mode 100644 index 0000000000..6bb214f8e4 --- /dev/null +++ b/app/javascript/glitch/components/list/statuses/index.js @@ -0,0 +1,209 @@ +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { ScrollContainer } from 'react-router-scroll'; +import PropTypes from 'prop-types'; +import IntersectionObserverWrapper from 'mastodon/features/ui/util/intersection_observer_wrapper'; +import { throttle } from 'lodash'; +import { defineMessages, injectIntl } from 'react-intl'; + +import StatusContainer from 'glitch/components/status/container'; +import CommonButton from 'glitch/components/common/button'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +@injectIntl +export default class ListStatuses extends ImmutablePureComponent { + + static propTypes = { + scrollKey: PropTypes.string.isRequired, + statusIds: ImmutablePropTypes.list.isRequired, + onScrollToBottom: PropTypes.func, + onScrollToTop: PropTypes.func, + onScroll: PropTypes.func, + trackScroll: PropTypes.bool, + shouldUpdateScroll: PropTypes.func, + isLoading: PropTypes.bool, + hasMore: PropTypes.bool, + prepend: PropTypes.node, + emptyMessage: PropTypes.node, + }; + static defaultProps = { + trackScroll: true, + }; + state = { + currentDetail: null, + }; + + intersectionObserverWrapper = new IntersectionObserverWrapper(); + + handleScroll = throttle(() => { + if (this.node) { + const { scrollTop, scrollHeight, clientHeight } = this.node; + const offset = scrollHeight - scrollTop - clientHeight; + this._oldScrollPosition = scrollHeight - scrollTop; + + if (400 > offset && this.props.onScrollToBottom && !this.props.isLoading) { + this.props.onScrollToBottom(); + } else if (scrollTop < 100 && this.props.onScrollToTop) { + this.props.onScrollToTop(); + } else if (this.props.onScroll) { + this.props.onScroll(); + } + } + }, 150, { + trailing: true, + }); + + componentDidMount () { + this.attachScrollListener(); + this.attachIntersectionObserver(); + + // Handle initial scroll posiiton + this.handleScroll(); + } + + componentDidUpdate (prevProps) { + // Reset the scroll position when a new toot comes in in order not to + // jerk the scrollbar around if you're already scrolled down the page. + if (prevProps.statusIds.size < this.props.statusIds.size && this._oldScrollPosition && this.node.scrollTop > 0) { + if (prevProps.statusIds.first() !== this.props.statusIds.first()) { + let newScrollTop = this.node.scrollHeight - this._oldScrollPosition; + if (this.node.scrollTop !== newScrollTop) { + this.node.scrollTop = newScrollTop; + } + } else { + this._oldScrollPosition = this.node.scrollHeight - this.node.scrollTop; + } + } + } + + componentWillUnmount () { + this.detachScrollListener(); + this.detachIntersectionObserver(); + } + + attachIntersectionObserver () { + this.intersectionObserverWrapper.connect({ + root: this.node, + rootMargin: '300% 0px', + }); + } + + detachIntersectionObserver () { + this.intersectionObserverWrapper.disconnect(); + } + + attachScrollListener () { + this.node.addEventListener('scroll', this.handleScroll); + } + + detachScrollListener () { + this.node.removeEventListener('scroll', this.handleScroll); + } + + setRef = (c) => { + this.node = c; + } + + handleLoadMore = (e) => { + e.preventDefault(); + this.props.onScrollToBottom(); + } + + handleKeyDown = (e) => { + if (['PageDown', 'PageUp'].includes(e.key) || (e.ctrlKey && ['End', 'Home'].includes(e.key))) { + const article = (() => { + switch (e.key) { + case 'PageDown': + return e.target.nodeName === 'ARTICLE' && e.target.nextElementSibling; + case 'PageUp': + return e.target.nodeName === 'ARTICLE' && e.target.previousElementSibling; + case 'End': + return this.node.querySelector('[role="feed"] > article:last-of-type'); + case 'Home': + return this.node.querySelector('[role="feed"] > article:first-of-type'); + default: + return null; + } + })(); + + + if (article) { + e.preventDefault(); + article.focus(); + article.scrollIntoView(); + } + } + } + + handleSetDetail = (id) => { + this.setState({ currentDetail : id }); + } + + render () { + const { + handleKeyDown, + handleLoadMore, + handleSetDetail, + intersectionObserverWrapper, + setRef, + } = this; + const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage, intl } = this.props; + const { currentDetail } = this.state; + + const loadMore = ( + 0 && hasMore} + onClick={handleLoadMore} + showTitle + title={intl.formatMessage(messages.load_more)} + /> + ); + let scrollableArea = null; + + if (isLoading || statusIds.size > 0 || !emptyMessage) { + scrollableArea = ( +
+
+ {prepend} + + {statusIds.map((statusId, index) => ( + + ))} + + {loadMore} +
+
+ ); + } else { + scrollableArea = ( +
+ {emptyMessage} +
+ ); + } + + if (trackScroll) { + return ( + + {scrollableArea} + + ); + } else { + return scrollableArea; + } + } + +} diff --git a/app/javascript/glitch/components/notification/index.js b/app/javascript/glitch/components/notification/index.js index b2e55aad51..c390e50d65 100644 --- a/app/javascript/glitch/components/notification/index.js +++ b/app/javascript/glitch/components/notification/index.js @@ -8,6 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; // Our imports // import StatusContainer from '../status/container'; import NotificationFollow from './follow'; +import NotificationOverlayContainer from './overlay/container'; export default class Notification extends ImmutablePureComponent { @@ -65,18 +66,25 @@ export default class Notification extends ImmutablePureComponent { 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; + return ( +
+ {(() => { + 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); + default: + return null; + } + })()} + +
+ ); } } diff --git a/app/javascript/glitch/components/notification/overlay/container.js b/app/javascript/glitch/components/notification/overlay/container.js index 089f615f08..48cf73aba0 100644 --- a/app/javascript/glitch/components/notification/overlay/container.js +++ b/app/javascript/glitch/components/notification/overlay/container.js @@ -1,40 +1,34 @@ -/* +// +// ============================== -`` -========================= -This container connects ``s to the Redux store. +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/notification/overlay/container -*/ +// * * * * * * * // -// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -/* +// Imports +// ------- -Imports: --------- - -*/ - -// Package imports // +// Package imports. import { connect } from 'react-redux'; -// Our imports // +// Mastodon imports. +import { markNotificationForDelete } from 'mastodon/actions/notifications'; + +// Our imports. import NotificationOverlay from './notification_overlay'; -import { markNotificationForDelete } from '../../../../mastodon/actions/notifications'; -// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * +// State mapping +// ------------- -/* +const mapStateToProps = state => ({ + show: state.getIn(['notifications', 'cleaningMode']), +}); -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. - -*/ +// Dispatch mapping +// ---------------- const mapDispatchToProps = dispatch => ({ onMarkForDelete(id, yes) { @@ -42,8 +36,4 @@ const mapDispatchToProps = dispatch => ({ }, }); -const mapStateToProps = state => ({ - show: state.getIn(['notifications', 'cleaningMode']), -}); - export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay); diff --git a/app/javascript/glitch/components/notification/overlay/notification_overlay.js b/app/javascript/glitch/components/notification/overlay/notification_overlay.js index aaca95cacb..bc0fe5e186 100644 --- a/app/javascript/glitch/components/notification/overlay/notification_overlay.js +++ b/app/javascript/glitch/components/notification/overlay/notification_overlay.js @@ -10,10 +10,6 @@ import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl } from 'react-intl'; -// Mastodon imports // - -// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * - const messages = defineMessages({ markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' }, }); diff --git a/app/javascript/glitch/components/status/action_bar.js b/app/javascript/glitch/components/status/action_bar.js deleted file mode 100644 index 7c73002c10..0000000000 --- a/app/javascript/glitch/components/status/action_bar.js +++ /dev/null @@ -1,160 +0,0 @@ -// 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' }, -}); - -@injectIntl -export default class StatusActionBar extends ImmutablePureComponent { - - static contextTypes = { - router: PropTypes.object, - }; - - static propTypes = { - status: ImmutablePropTypes.map.isRequired, - onReply: PropTypes.func, - onFavourite: PropTypes.func, - onReblog: PropTypes.func, - onDelete: PropTypes.func, - onMention: PropTypes.func, - onMute: PropTypes.func, - onBlock: PropTypes.func, - onReport: PropTypes.func, - onMuteConversation: 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); - } - - 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(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 ( -
- - - - -
- -
- - -
- ); - } - -} diff --git a/app/javascript/glitch/components/status/action_bar/index.js b/app/javascript/glitch/components/status/action_bar/index.js new file mode 100644 index 0000000000..03e0765bee --- /dev/null +++ b/app/javascript/glitch/components/status/action_bar/index.js @@ -0,0 +1,268 @@ +// +// ======== + +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/status/action_bar + +// For more information, please contact: +// @kibi@glitch.social + +// * * * * * * * // + +// Imports +// ------- + +// Package imports. +import PropTypes from 'prop-types'; +import React from 'react'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { defineMessages } from 'react-intl'; + +// Mastodon imports. +import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; + +// Our imports. +import CommonButton from 'glitch/components/common/button'; + +// Stylesheet imports. +import './style'; + +// * * * * * * * // + +// Initial setup +// ------------- + +// Holds our localization messages. +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' }, + share: + { id: 'status.share', defaultMessage: 'Share' }, + more: + { id: 'status.more', defaultMessage: 'More' }, +}); + +// * * * * * * * // + +// The component +// ------------- + +export default class StatusActionBar extends ImmutablePureComponent { + + // Props. + static propTypes = { + detailed: PropTypes.bool, + handler: PropTypes.objectOf(PropTypes.func).isRequired, + history: PropTypes.object, + intl: PropTypes.object.isRequired, + me: PropTypes.number, + status: ImmutablePropTypes.map.isRequired, + }; + + // These handle all of our actions. + handleReplyClick = () => { + const { handler, history, status } = this.props; + handler.reply(status, { history }); // hack + } + handleFavouriteClick = () => { + const { handler, status } = this.props; + handler.favourite(status); + } + handleReblogClick = (e) => { + const { handler, status } = this.props; + handler.reblog(status, e.shiftKey); + } + handleDeleteClick = () => { + const { handler, status } = this.props; + handler.delete(status); + } + handleMentionClick = () => { + const { handler, history, status } = this.props; + handler.mention(status.get('account'), { history }); // hack + } + handleMuteClick = () => { + const { handler, status } = this.props; + handler.mute(status.get('account')); + } + handleBlockClick = () => { + const { handler, status } = this.props; + handler.block(status.get('account')); + } + handleOpen = () => { + const { history, status } = this.props; + history.push(`/statuses/${status.get('id')}`); + } + handleReport = () => { + const { handler, status } = this.props; + handler.report(status); + } + handleShare = () => { + const { status } = this.props; + navigator.share({ + text: status.get('search_index'), + url: status.get('url'), + }); + } + handleConversationMuteClick = () => { + const { handler, status } = this.props; + handler.muteConversation(status); + } + + // Renders our component. + render () { + const { + handleBlockClick, + handleConversationMuteClick, + handleDeleteClick, + handleFavouriteClick, + handleMentionClick, + handleMuteClick, + handleOpen, + handleReblogClick, + handleReplyClick, + handleReport, + handleShare, + } = this; + const { detailed, intl, me, status } = this.props; + const account = status.get('account'); + const reblogDisabled = status.get('visibility') === 'private' || status.get('visibility') === 'direct'; + const reblogTitle = reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog); + const mutingConversation = status.get('muted'); + const anonymousAccess = !me; + let menu = []; + let replyIcon; + let replyTitle; + + // This builds our menu. + if (!detailed) { + menu.push({ + text: intl.formatMessage(messages.open), + action: handleOpen, + }); + menu.push(null); + } + menu.push({ + text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), + action: handleConversationMuteClick, + }); + menu.push(null); + if (account.get('id') === me) { + menu.push({ + text: intl.formatMessage(messages.delete), + action: handleDeleteClick, + }); + } else { + menu.push({ + text: intl.formatMessage(messages.mention, { + name: account.get('username'), + }), + action: handleMentionClick, + }); + menu.push(null); + menu.push({ + text: intl.formatMessage(messages.mute, { + name: account.get('username'), + }), + action: handleMuteClick, + }); + menu.push({ + text: intl.formatMessage(messages.block, { + name: account.get('username'), + }), + action: handleBlockClick, + }); + menu.push({ + text: intl.formatMessage(messages.report, { + name: account.get('username'), + }), + action: handleReport, + }); + } + + // This selects our reply icon. + 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); + } + + // Now we can render the component. + return ( +
+ + + + { + 'share' in navigator ? ( + + ) : null + } +
+ +
+
+ ); + } + +} diff --git a/app/javascript/glitch/components/status/action_bar/style.scss b/app/javascript/glitch/components/status/action_bar/style.scss new file mode 100644 index 0000000000..c259bddcab --- /dev/null +++ b/app/javascript/glitch/components/status/action_bar/style.scss @@ -0,0 +1,28 @@ +@import 'variables'; + +.glitch.glitch__status__action-bar { + display: block; + height: 1.25em; + font-size: 1.25em; + line-height: 1; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + + // 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; + } + } +} diff --git a/app/javascript/glitch/components/status/container.js b/app/javascript/glitch/components/status/container.js index 1d572e0e71..13705331f2 100644 --- a/app/javascript/glitch/components/status/container.js +++ b/app/javascript/glitch/components/status/container.js @@ -1,73 +1,64 @@ -/* +// +// ================= -`` -=================== +// For code documentation, please see: +// https://glitch-soc.github.io/docs/javascript/glitch/status/container -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 . +// For more information, please contact: +// @kibi@glitch.social -*/ +// * * * * * * * // - /* * * * */ +// Imports +// ------- -/* - -Imports: --------- - -*/ - -// Package imports // +// Package imports. import React from 'react'; -import { connect } from 'react-redux'; import { defineMessages, injectIntl, FormattedMessage, } from 'react-intl'; +import { connect } from 'react-redux'; +import { withRouter } from 'react-router'; +import { createStructuredSelector } from 'reselect'; -// Mastodon imports // -import { makeGetStatus } from '../../../mastodon/selectors'; +// Mastodon imports. +import { blockAccount, muteAccount } from 'mastodon/actions/accounts'; import { replyCompose, mentionCompose, -} from '../../../mastodon/actions/compose'; +} from 'mastodon/actions/compose'; import { reblog, favourite, unreblog, unfavourite, -} from '../../../mastodon/actions/interactions'; -import { - blockAccount, - muteAccount, -} from '../../../mastodon/actions/accounts'; +} from 'mastodon/actions/interactions'; +import { openModal } from 'mastodon/actions/modal'; +import { initReport } from 'mastodon/actions/reports'; import { muteStatus, unmuteStatus, deleteStatus, -} from '../../../mastodon/actions/statuses'; -import { initReport } from '../../../mastodon/actions/reports'; -import { openModal } from '../../../mastodon/actions/modal'; +} from 'mastodon/actions/statuses'; +import { fetchStatusCard } from 'mastodon/actions/cards'; -// Our imports // +// Our imports. import Status from '.'; +import makeStatusSelector from 'glitch/selectors/status'; - /* * * * */ +// * * * * * * * // -/* - -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. - -*/ +// Initial setup +// ------------- +// Localization messages. const messages = defineMessages({ + blockConfirm : { + id : 'confirmations.block.confirm', + defaultMessage : 'Block', + }, deleteConfirm : { id : 'confirmations.delete.confirm', defaultMessage : 'Delete', @@ -76,176 +67,146 @@ const messages = defineMessages({ 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. - -*/ +// State mapping +// ------------- +// We wrap our `mapStateToProps()` function in a +// `makeMapStateToProps()` to give us a closure and preserve +// `makeGetStatus()`'s value. const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); + const statusSelector = makeStatusSelector(); - const mapStateToProps = (state, ownProps) => { - - let status = getStatus(state, ownProps.id); + // State mapping. + return (state, ownProps) => { + let status = statusSelector(state, ownProps.id); let reblogStatus = status.get('reblog', null); - let account = undefined; + let comrade = undefined; let prepend = undefined; -/* - -Here we process reblogs. If our status is a reblog, then we create a -`prependMessage` to pass along to our `` along with the -reblogger's `account`, and set `coreStatus` (the one we will actually -render) to the status which has been reblogged. - -*/ - + // Processes reblogs and generates their prepend. if (reblogStatus !== null && typeof reblogStatus === 'object') { - account = status.get('account'); + comrade = status.get('account'); status = reblogStatus; - prepend = 'reblogged_by'; + prepend = 'reblogged'; } -/* - -Here are the props we pass to ``. - -*/ - + // This is what we pass to . 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']), + autoPlayGif: state.getIn(['meta', 'auto_play_gif']), + comrade: comrade || ownProps.comrade, + deleteModal: state.getIn(['meta', 'delete_modal']), + me: state.getIn(['meta', 'me']), + prepend: prepend || ownProps.prepend, + reblogModal: state.getIn(['meta', 'boost_modal']), + settings: state.get('local_settings'), + status: status, }; }; - - return mapStateToProps; }; - /* * * * */ +// * * * * * * * // -/* +// Dispatch mapping +// ---------------- -Dispatch mapping: ------------------ +const makeMapDispatchToProps = (dispatch) => { + const dispatchSelector = createStructuredSelector({ + handler: ({ intl }) => ({ + block (account) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.get('id'))), + })); + }, + delete (status) { + if (!this.deleteModal) { // TODO: THIS IS BORKN (this refers to handler) + 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'))), + })); + } + }, + favourite (status) { + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }, + fetchCard (status) { + dispatch(fetchStatusCard(status.get('id'))); + }, + mention (account, router) { + dispatch(mentionCompose(account, router)); + }, + modalReblog (status) { + dispatch(reblog(status)); + }, + mute (account) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.muteConfirm), + onConfirm: () => dispatch(muteAccount(account.get('id'))), + })); + }, + muteConversation (status) { + if (status.get('muted')) { + dispatch(unmuteStatus(status.get('id'))); + } else { + dispatch(muteStatus(status.get('id'))); + } + }, + openMedia (media, index) { + dispatch(openModal('MEDIA', { media, index })); + }, + openVideo (media, time) { + dispatch(openModal('VIDEO', { media, time })); + }, + reblog (status, withShift) { + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else { + if (withShift || !this.reblogModal) { // TODO: THIS IS BORKN (this refers to handler) + this.modalReblog(status); + } else { + dispatch(openModal('BOOST', { status, onReblog: this.modalReblog })); + } + } + }, + reply (status, router) { + dispatch(replyCompose(status, router)); + }, + report (status) { + dispatch(initReport(status.get('account'), status)); + }, + }), + }); + return (_, ownProps) => dispatchSelector(ownProps); +}; -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: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - })); - }, - - onReport (status) { - dispatch(initReport(status.get('account'), status)); - }, - - onMute (account) { - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - 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'))); - } - }, -}); +// Connecting +// ---------- +// `connect` will only update when its resultant props change. So +// `withRouter` won't get called unless an update is already planned. +// This is intended behaviour because we only care about the (mutable) +// `history` object. export default injectIntl( - connect(makeMapStateToProps, mapDispatchToProps)(Status) + connect(makeMapStateToProps, makeMapDispatchToProps)( + withRouter(Status) + ) ); diff --git a/app/javascript/glitch/components/status/content.js b/app/javascript/glitch/components/status/content.js deleted file mode 100644 index 06fe04ce0a..0000000000 --- a/app/javascript/glitch/components/status/content.js +++ /dev/null @@ -1,247 +0,0 @@ -// 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 => ( - - @{item.get('username')} - - )).reduce((aggregate, item) => [...aggregate, item, ' '], []); - - const toggleText = hidden ? [ - , - mediaIcon ? ( -