diff --git a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap index 0ff8f9961..f5ca74772 100644 --- a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap @@ -2,81 +2,85 @@ exports[` renders correctly 1`] = `
`; diff --git a/app/soapbox/components/dropdown_menu.js b/app/soapbox/components/dropdown_menu.js deleted file mode 100644 index c471ac27b..000000000 --- a/app/soapbox/components/dropdown_menu.js +++ /dev/null @@ -1,342 +0,0 @@ -import classNames from 'classnames'; -import { supportsPassiveEvents } from 'detect-passive-events'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import spring from 'react-motion/lib/spring'; -import Overlay from 'react-overlays/lib/Overlay'; -import { withRouter } from 'react-router-dom'; - -import Icon from 'soapbox/components/icon'; - -import Motion from '../features/ui/util/optional_motion'; - -import { IconButton } from './ui'; - -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; -let id = 0; - -@withRouter -class DropdownMenu extends React.PureComponent { - - static propTypes = { - items: PropTypes.array.isRequired, - onClose: PropTypes.func.isRequired, - style: PropTypes.object, - placement: PropTypes.string, - arrowOffsetLeft: PropTypes.string, - arrowOffsetTop: PropTypes.string, - openedViaKeyboard: PropTypes.bool, - history: PropTypes.object, - }; - - static defaultProps = { - style: {}, - placement: 'bottom', - }; - - state = { - mounted: false, - }; - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - } - } - - componentDidMount() { - document.addEventListener('click', this.handleDocumentClick, false); - document.addEventListener('keydown', this.handleKeyDown, false); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem && this.props.openedViaKeyboard) { - this.focusedItem.focus({ preventScroll: true }); - } - this.setState({ mounted: true }); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleDocumentClick, false); - document.removeEventListener('keydown', this.handleKeyDown, false); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - } - - setFocusRef = c => { - this.focusedItem = c; - } - - handleKeyDown = e => { - const items = Array.from(this.node.getElementsByTagName('a')); - const index = items.indexOf(document.activeElement); - let element = null; - - switch(e.key) { - case 'ArrowDown': - element = items[index+1] || items[0]; - break; - case 'ArrowUp': - element = items[index-1] || items[items.length-1]; - break; - case 'Tab': - if (e.shiftKey) { - element = items[index-1] || items[items.length-1]; - } else { - element = items[index+1] || items[0]; - } - break; - case 'Home': - element = items[0]; - break; - case 'End': - element = items[items.length-1]; - break; - case 'Escape': - this.props.onClose(); - break; - } - - if (element) { - element.focus(); - e.preventDefault(); - e.stopPropagation(); - } - } - - handleItemKeyPress = e => { - if (e.key === 'Enter' || e.key === ' ') { - this.handleClick(e); - } - } - - handleClick = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const { action, to } = this.props.items[i]; - - this.props.onClose(); - - if (typeof action === 'function') { - e.preventDefault(); - action(e); - } else if (to) { - e.preventDefault(); - this.props.history.push(to); - } - } - - handleMiddleClick = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const { middleClick } = this.props.items[i]; - - this.props.onClose(); - - if (e.button === 1 && typeof middleClick === 'function') { - e.preventDefault(); - middleClick(e); - } - } - - handleAuxClick = e => { - if (e.button === 1) { - this.handleMiddleClick(e); - } - } - - renderItem(option, i) { - if (option === null) { - return
  • ; - } - - const { text, href, to, newTab, isLogout, icon, destructive } = option; - - return ( -
  • - - {icon && } - {text} - -
  • - ); - } - - render() { - const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; - const { mounted } = this.state; - return ( - - {({ opacity, scaleX, scaleY }) => ( - // It should not be transformed when mounting because the resulting - // size will be used to determine the coordinate of the menu by - // react-overlays -
    -
    -
      - {items.map((option, i) => this.renderItem(option, i))} -
    -
    - )} - - ); - } - -} - -export default @withRouter -class Dropdown extends React.PureComponent { - - static propTypes = { - icon: PropTypes.string, - src: PropTypes.string, - items: PropTypes.array.isRequired, - size: PropTypes.number, - active: PropTypes.bool, - pressed: PropTypes.bool, - title: PropTypes.string, - disabled: PropTypes.bool, - status: ImmutablePropTypes.record, - isUserTouching: PropTypes.func, - isModalOpen: PropTypes.bool.isRequired, - onOpen: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - dropdownPlacement: PropTypes.string, - openDropdownId: PropTypes.number, - openedViaKeyboard: PropTypes.bool, - text: PropTypes.string, - onShiftClick: PropTypes.func, - history: PropTypes.object, - }; - - static defaultProps = { - title: 'Menu', - }; - - state = { - id: id++, - }; - - handleClick = e => { - const { onOpen, onShiftClick, openDropdownId } = this.props; - e.stopPropagation(); - - if (onShiftClick && e.shiftKey) { - e.preventDefault(); - onShiftClick(e); - } else if (this.state.id === openDropdownId) { - this.handleClose(); - } else { - const { top } = e.target.getBoundingClientRect(); - const placement = top * 2 < innerHeight ? 'bottom' : 'top'; - - onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click'); - } - } - - handleClose = () => { - if (this.activeElement) { - this.activeElement.focus(); - this.activeElement = null; - } - this.props.onClose(this.state.id); - } - - handleMouseDown = () => { - if (!this.state.open) { - this.activeElement = document.activeElement; - } - } - - handleButtonKeyDown = (e) => { - switch(e.key) { - case ' ': - case 'Enter': - this.handleMouseDown(); - break; - } - } - - handleKeyPress = (e) => { - switch(e.key) { - case ' ': - case 'Enter': - this.handleClick(e); - e.stopPropagation(); - e.preventDefault(); - break; - } - } - - handleItemClick = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const { action, to } = this.props.items[i]; - - this.handleClose(); - e.preventDefault(); - e.stopPropagation(); - - if (typeof action === 'function') { - action(e); - } else if (to) { - this.props.history.push(to); - } - } - - setTargetRef = c => { - this.target = c; - } - - findTarget = () => { - return this.target; - } - - componentWillUnmount = () => { - if (this.state.id === this.props.openDropdownId) { - this.handleClose(); - } - } - - render() { - const { src, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard, pressed, text } = this.props; - const open = this.state.id === openDropdownId; - - return ( - <> - - - - - - - ); - } - -} diff --git a/app/soapbox/components/dropdown_menu.tsx b/app/soapbox/components/dropdown_menu.tsx new file mode 100644 index 000000000..d3efa6865 --- /dev/null +++ b/app/soapbox/components/dropdown_menu.tsx @@ -0,0 +1,401 @@ +import classNames from 'classnames'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import React from 'react'; +import { spring } from 'react-motion'; +// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions +import Overlay from 'react-overlays/lib/Overlay'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; + +import Icon from 'soapbox/components/icon'; +import { IconButton } from 'soapbox/components/ui'; +import Motion from 'soapbox/features/ui/util/optional_motion'; + +import type { Status } from 'soapbox/types/entities'; + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; +let id = 0; + +export interface MenuItem { + action: React.EventHandler, + middleClick?: React.EventHandler, + text: string, + href?: string, + to?: string, + newTab?: boolean, + isLogout?: boolean, + icon: string, + destructive?: boolean, +} + +export type Menu = Array; + +interface IDropdownMenu extends RouteComponentProps { + items: Menu, + onClose: () => void, + style?: React.CSSProperties, + placement?: DropdownPlacement, + arrowOffsetLeft?: string, + arrowOffsetTop?: string, + openedViaKeyboard: boolean, +} + +interface IDropdownMenuState { + mounted: boolean, +} + +class DropdownMenu extends React.PureComponent { + + static defaultProps: Partial = { + style: {}, + placement: 'bottom', + }; + + state = { + mounted: false, + }; + + node: HTMLDivElement | null = null; + focusedItem: HTMLAnchorElement | null = null; + + handleDocumentClick = (e: Event) => { + if (this.node && !this.node.contains(e.target as Node)) { + this.props.onClose(); + } + } + + componentDidMount() { + document.addEventListener('click', this.handleDocumentClick, false); + document.addEventListener('keydown', this.handleKeyDown, false); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus({ preventScroll: true }); + } + this.setState({ mounted: true }); + } + + componentWillUnmount() { + document.removeEventListener('click', this.handleDocumentClick); + document.removeEventListener('keydown', this.handleKeyDown); + document.removeEventListener('touchend', this.handleDocumentClick); + } + + setRef: React.RefCallback = c => { + this.node = c; + } + + setFocusRef: React.RefCallback = c => { + this.focusedItem = c; + } + + handleKeyDown = (e: KeyboardEvent) => { + if (!this.node) return; + + const items = Array.from(this.node.getElementsByTagName('a')); + const index = items.indexOf(document.activeElement as any); + let element = null; + + switch(e.key) { + case 'ArrowDown': + element = items[index+1] || items[0]; + break; + case 'ArrowUp': + element = items[index-1] || items[items.length-1]; + break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + break; + case 'Home': + element = items[0]; + break; + case 'End': + element = items[items.length-1]; + break; + case 'Escape': + this.props.onClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + } + + handleItemKeyPress: React.EventHandler = e => { + if (e.key === 'Enter' || e.key === ' ') { + this.handleClick(e); + } + } + + handleClick: React.EventHandler = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const item = this.props.items[i]; + if (!item) return; + const { action, to } = item; + + this.props.onClose(); + + if (typeof action === 'function') { + e.preventDefault(); + action(e); + } else if (to) { + e.preventDefault(); + this.props.history.push(to); + } + } + + handleMiddleClick: React.EventHandler = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const item = this.props.items[i]; + if (!item) return; + const { middleClick } = item; + + this.props.onClose(); + + if (e.button === 1 && typeof middleClick === 'function') { + e.preventDefault(); + middleClick(e); + } + } + + handleAuxClick: React.EventHandler = e => { + if (e.button === 1) { + this.handleMiddleClick(e); + } + } + + renderItem(option: MenuItem | null, i: number): JSX.Element { + if (option === null) { + return
  • ; + } + + const { text, href, to, newTab, isLogout, icon, destructive } = option; + + return ( +
  • + + {icon && } + {text} + +
  • + ); + } + + render() { + const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; + const { mounted } = this.state; + return ( + + {({ opacity, scaleX, scaleY }) => ( + // It should not be transformed when mounting because the resulting + // size will be used to determine the coordinate of the menu by + // react-overlays +
    +
    +
      + {items.map((option, i) => this.renderItem(option, i))} +
    +
    + )} + + ); + } + +} + +const RouterDropdownMenu = withRouter(DropdownMenu); + +export interface IDropdown extends RouteComponentProps { + icon?: string, + src?: string, + items: Menu, + size?: number, + active?: boolean, + pressed?: boolean, + title?: string, + disabled?: boolean, + status?: Status, + isUserTouching?: () => boolean, + isModalOpen?: boolean, + onOpen?: ( + id: number, + onItemClick: React.EventHandler, + dropdownPlacement: DropdownPlacement, + keyboard: boolean, + ) => void, + onClose?: (id: number) => void, + dropdownPlacement?: string, + openDropdownId?: number, + openedViaKeyboard?: boolean, + text?: string, + onShiftClick?: React.EventHandler, + children?: JSX.Element, +} + +interface IDropdownState { + id: number, + open: boolean, +} + +export type DropdownPlacement = 'top' | 'bottom'; + +class Dropdown extends React.PureComponent { + + static defaultProps: Partial = { + title: 'Menu', + }; + + state = { + id: id++, + open: false, + }; + + target: HTMLButtonElement | null = null; + activeElement: Element | null = null; + + handleClick: React.EventHandler | React.KeyboardEvent> = e => { + const { onOpen, onShiftClick, openDropdownId } = this.props; + e.stopPropagation(); + + if (onShiftClick && e.shiftKey) { + e.preventDefault(); + onShiftClick(e); + } else if (this.state.id === openDropdownId) { + this.handleClose(); + } else if(onOpen) { + const { top } = e.currentTarget.getBoundingClientRect(); + const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top'; + + onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click'); + } + } + + handleClose = () => { + if (this.activeElement && this.activeElement === this.target) { + (this.activeElement as HTMLButtonElement).focus(); + this.activeElement = null; + } + + if (this.props.onClose) { + this.props.onClose(this.state.id); + } + } + + handleMouseDown: React.EventHandler = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown: React.EventHandler = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(e); + break; + } + } + + handleKeyPress: React.EventHandler> = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleClick(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + + handleItemClick: React.EventHandler = e => { + const i = Number(e.currentTarget.getAttribute('data-index')); + const item = this.props.items[i]; + if (!item) return; + + const { action, to } = item; + + this.handleClose(); + e.preventDefault(); + e.stopPropagation(); + + if (typeof action === 'function') { + action(e); + } else if (to) { + this.props.history?.push(to); + } + } + + setTargetRef: React.RefCallback = c => { + this.target = c; + } + + findTarget = () => { + return this.target; + } + + componentWillUnmount = () => { + if (this.state.id === this.props.openDropdownId) { + this.handleClose(); + } + } + + render() { + const { src = require('@tabler/icons/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children } = this.props; + const open = this.state.id === openDropdownId; + + return ( + <> + {children ? ( + React.cloneElement(children, { + disabled, + onClick: this.handleClick, + onMouseDown: this.handleMouseDown, + onKeyDown: this.handleButtonKeyDown, + onKeyPress: this.handleKeyPress, + ref: this.setTargetRef, + }) + ) : ( + + )} + + + + + + ); + } + +} + +export default withRouter(Dropdown); diff --git a/app/soapbox/components/emoji_selector.js b/app/soapbox/components/emoji_selector.js deleted file mode 100644 index 73db189a3..000000000 --- a/app/soapbox/components/emoji_selector.js +++ /dev/null @@ -1,124 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { HotKeys } from 'react-hotkeys'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import emojify from 'soapbox/features/emoji/emoji'; - -const mapStateToProps = state => ({ - allowedEmoji: getSoapboxConfig(state).get('allowedEmoji'), -}); - -export default @connect(mapStateToProps) -class EmojiSelector extends ImmutablePureComponent { - - static propTypes = { - onReact: PropTypes.func.isRequired, - onUnfocus: PropTypes.func, - visible: PropTypes.bool, - focused: PropTypes.bool, - } - - static defaultProps = { - onReact: () => {}, - onUnfocus: () => {}, - visible: false, - } - - handleBlur = e => { - const { focused, onUnfocus } = this.props; - - if (focused && (!e.relatedTarget || !e.relatedTarget.classList.contains('emoji-react-selector__emoji'))) { - onUnfocus(); - } - } - - _selectPreviousEmoji = i => { - if (i !== 0) { - this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus(); - } else { - this.node.querySelector('.emoji-react-selector__emoji:last-child').focus(); - } - }; - - _selectNextEmoji = i => { - if (i !== this.props.allowedEmoji.size - 1) { - this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus(); - } else { - this.node.querySelector('.emoji-react-selector__emoji:first-child').focus(); - } - }; - - handleKeyDown = i => e => { - const { onUnfocus } = this.props; - - switch (e.key) { - case 'Tab': - e.preventDefault(); - if (e.shiftKey) this._selectPreviousEmoji(i); - else this._selectNextEmoji(i); - break; - case 'Left': - case 'ArrowLeft': - this._selectPreviousEmoji(i); - break; - case 'Right': - case 'ArrowRight': - this._selectNextEmoji(i); - break; - case 'Escape': - onUnfocus(); - break; - } - } - - handleReact = emoji => () => { - const { onReact, focused, onUnfocus } = this.props; - - onReact(emoji)(); - - if (focused) { - onUnfocus(); - } - } - - handlers = { - open: () => {}, - }; - - setRef = c => { - this.node = c; - } - - render() { - const { visible, focused, allowedEmoji } = this.props; - - return ( - -
    - {allowedEmoji.map((emoji, i) => ( -
    -
    - ); - } - -} diff --git a/app/soapbox/components/emoji_selector.tsx b/app/soapbox/components/emoji_selector.tsx new file mode 100644 index 000000000..0533f6a69 --- /dev/null +++ b/app/soapbox/components/emoji_selector.tsx @@ -0,0 +1,142 @@ +// import classNames from 'classnames'; +import React from 'react'; +import { HotKeys } from 'react-hotkeys'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { getSoapboxConfig } from 'soapbox/actions/soapbox'; +import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui'; + +import type { List as ImmutableList } from 'immutable'; +import type { RootState } from 'soapbox/store'; + +const mapStateToProps = (state: RootState) => ({ + allowedEmoji: getSoapboxConfig(state).allowedEmoji, +}); + +interface IEmojiSelector { + allowedEmoji: ImmutableList, + onReact: (emoji: string) => void, + onUnfocus: () => void, + visible: boolean, + focused?: boolean, +} + +class EmojiSelector extends ImmutablePureComponent { + + static defaultProps: Partial = { + onReact: () => {}, + onUnfocus: () => {}, + visible: false, + } + + node?: HTMLDivElement = undefined; + + handleBlur: React.FocusEventHandler = e => { + const { focused, onUnfocus } = this.props; + + if (focused && (!e.currentTarget || !e.currentTarget.classList.contains('emoji-react-selector__emoji'))) { + onUnfocus(); + } + } + + _selectPreviousEmoji = (i: number): void => { + if (!this.node) return; + + if (i !== 0) { + const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`); + button?.focus(); + } else { + const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:last-child'); + button?.focus(); + } + }; + + _selectNextEmoji = (i: number) => { + if (!this.node) return; + + if (i !== this.props.allowedEmoji.size - 1) { + const button: HTMLButtonElement | null = this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`); + button?.focus(); + } else { + const button: HTMLButtonElement | null = this.node.querySelector('.emoji-react-selector__emoji:first-child'); + button?.focus(); + } + }; + + handleKeyDown = (i: number): React.KeyboardEventHandler => e => { + const { onUnfocus } = this.props; + + switch (e.key) { + case 'Tab': + e.preventDefault(); + if (e.shiftKey) this._selectPreviousEmoji(i); + else this._selectNextEmoji(i); + break; + case 'Left': + case 'ArrowLeft': + this._selectPreviousEmoji(i); + break; + case 'Right': + case 'ArrowRight': + this._selectNextEmoji(i); + break; + case 'Escape': + onUnfocus(); + break; + } + } + + handleReact = (emoji: string) => (): void => { + const { onReact, focused, onUnfocus } = this.props; + + onReact(emoji); + + if (focused) { + onUnfocus(); + } + } + + handlers = { + open: () => {}, + }; + + setRef = (c: HTMLDivElement): void => { + this.node = c; + } + + render() { + const { visible, focused, allowedEmoji, onReact } = this.props; + + return ( + + {/*
    + {allowedEmoji.map((emoji, i) => ( + + ))} +
    */} + +
    + ); + } + +} + +export default connect(mapStateToProps)(EmojiSelector); diff --git a/app/soapbox/components/hoverable.tsx b/app/soapbox/components/hoverable.tsx new file mode 100644 index 000000000..751c413c1 --- /dev/null +++ b/app/soapbox/components/hoverable.tsx @@ -0,0 +1,63 @@ +import classNames from 'classnames'; +import React, { useState, useRef } from 'react'; +import { usePopper } from 'react-popper'; + +interface IHoverable { + component: JSX.Element, +} + +/** Wrapper to render a given component when hovered */ +const Hoverable: React.FC = ({ + component, + children, +}): JSX.Element => { + + const [portalActive, setPortalActive] = useState(false); + + const ref = useRef(null); + const popperRef = useRef(null); + + const handleMouseEnter = () => { + setPortalActive(true); + }; + + const handleMouseLeave = () => { + setPortalActive(false); + }; + + const { styles, attributes } = usePopper(ref.current, popperRef.current, { + placement: 'top-start', + strategy: 'fixed', + modifiers: [ + { + name: 'offset', + options: { + offset: [-10, 0], + }, + }, + ], + }); + + return ( +
    + {children} + +
    + {component} +
    +
    + ); +}; + +export default Hoverable; diff --git a/app/soapbox/components/profile_hover_card.js b/app/soapbox/components/profile_hover_card.js index a1da1e435..c435261a4 100644 --- a/app/soapbox/components/profile_hover_card.js +++ b/app/soapbox/components/profile_hover_card.js @@ -17,7 +17,6 @@ import ActionButton from 'soapbox/features/ui/components/action_button'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import { UserPanel } from 'soapbox/features/ui/util/async-components'; import { makeGetAccount } from 'soapbox/selectors'; -import { isAdmin, isModerator } from 'soapbox/utils/accounts'; import { showProfileHoverCard } from './hover_ref_wrapper'; import { Card, CardBody, Stack, Text } from './ui'; @@ -27,9 +26,9 @@ const getAccount = makeGetAccount(); const getBadges = (account) => { const badges = []; - if (isAdmin(account)) { + if (account.admin) { badges.push(); - } else if (isModerator(account)) { + } else if (account.moderator) { badges.push(); } diff --git a/app/soapbox/components/scrollable_list.js b/app/soapbox/components/scrollable_list.js index 5e2f150d9..c1b8c1a52 100644 --- a/app/soapbox/components/scrollable_list.js +++ b/app/soapbox/components/scrollable_list.js @@ -107,7 +107,7 @@ class ScrollableList extends PureComponent { this.attachScrollListener(); this.attachIntersectionObserver(); - // Handle initial scroll posiiton + // Handle initial scroll position this.handleScroll(); } @@ -115,7 +115,7 @@ class ScrollableList extends PureComponent { if (this.documentElement && (this.documentElement.scrollTop > 0 || this.mouseMovedRecently)) { return { height: this.documentElement.scrollHeight, top: this.documentElement.scrollTop }; } else { - return null; + return undefined; } } diff --git a/app/soapbox/components/sidebar-navigation-link.tsx b/app/soapbox/components/sidebar-navigation-link.tsx index bd0567c03..4d031d5fd 100644 --- a/app/soapbox/components/sidebar-navigation-link.tsx +++ b/app/soapbox/components/sidebar-navigation-link.tsx @@ -37,14 +37,16 @@ const SidebarNavigationLink = ({ icon, text, to, count }: ISidebarNavigationLink ) : null} - +
    + +
    {text} diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index 4b34161df..3d069d10e 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -70,7 +70,7 @@ const SidebarNavigation = () => { ) )} - {/* {(account && isStaff(account)) && ( + {/* {(account && account.staff) && ( { - {isStaff(account) && ( + {account.staff && ( + ); +}); + +export default StatusActionButton; diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.tsx similarity index 54% rename from app/soapbox/components/status.js rename to app/soapbox/components/status.tsx index 214daf6f6..8276bed34 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.tsx @@ -1,11 +1,9 @@ import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; import { HotKeys } from 'react-hotkeys'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl, FormattedMessage } from 'react-intl'; -import { NavLink, withRouter } from 'react-router-dom'; +import { injectIntl, FormattedMessage, IntlShape } from 'react-intl'; +import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card'; @@ -22,13 +20,27 @@ import StatusContent from './status_content'; import StatusReplyMentions from './status_reply_mentions'; import { HStack, Text } from './ui'; -export const textForScreenReader = (intl, status, rebloggedByText = false) => { - const displayName = status.getIn(['account', 'display_name']); +import type { History } from 'history'; +import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import type { + Account as AccountEntity, + Attachment as AttachmentEntity, + Status as StatusEntity, +} from 'soapbox/types/entities'; + +// Defined in components/scrollable_list +type ScrollPosition = { height: number, top: number }; + +export const textForScreenReader = (intl: IntlShape, status: StatusEntity, rebloggedByText?: string): string => { + const { account } = status; + if (!account || typeof account !== 'object') return ''; + + const displayName = account.display_name; const values = [ - displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, - status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), - intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), + displayName.length === 0 ? account.acct.split('@')[0] : displayName, + status.spoiler_text && status.hidden ? status.spoiler_text : status.search_index.slice(status.spoiler_text.length), + intl.formatDate(status.created_at, { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), status.getIn(['account', 'acct']), ]; @@ -39,96 +51,106 @@ export const textForScreenReader = (intl, status, rebloggedByText = false) => { return values.join(', '); }; -export const defaultMediaVisibility = (status, displayMedia) => { - if (!status) { - return undefined; - } +export const defaultMediaVisibility = (status: StatusEntity, displayMedia: string): boolean => { + if (!status) return false; - if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - status = status.get('reblog'); + if (status.reblog && typeof status.reblog === 'object') { + status = status.reblog; } - return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all'); + return (displayMedia !== 'hide_all' && !status.sensitive || displayMedia === 'show_all'); }; -export default @injectIntl @withRouter -class Status extends ImmutablePureComponent { - - static propTypes = { - status: ImmutablePropTypes.record, - account: ImmutablePropTypes.record, - otherAccounts: ImmutablePropTypes.list, - onClick: PropTypes.func, - onReply: PropTypes.func, - onFavourite: PropTypes.func, - onReblog: PropTypes.func, - onQuote: PropTypes.func, - onDelete: PropTypes.func, - onDirect: PropTypes.func, - onChat: PropTypes.func, - onMention: PropTypes.func, - onPin: PropTypes.func, - onOpenMedia: PropTypes.func, - onOpenVideo: PropTypes.func, - onOpenAudio: PropTypes.func, - onBlock: PropTypes.func, - onEmbed: PropTypes.func, - onHeightChange: PropTypes.func, - onToggleHidden: PropTypes.func, - onShowHoverProfileCard: PropTypes.func, - muted: PropTypes.bool, - hidden: PropTypes.bool, - unread: PropTypes.bool, - onMoveUp: PropTypes.func, - onMoveDown: PropTypes.func, - getScrollPosition: PropTypes.func, - updateScrollBottom: PropTypes.func, - cacheMediaWidth: PropTypes.func, - cachedMediaWidth: PropTypes.number, - group: ImmutablePropTypes.map, - displayMedia: PropTypes.string, - allowedEmoji: ImmutablePropTypes.list, - focusable: PropTypes.bool, - history: PropTypes.object, - }; +interface IStatus extends RouteComponentProps { + intl: IntlShape, + status: StatusEntity, + account: AccountEntity, + otherAccounts: ImmutableList, + onClick: () => void, + onReply: (status: StatusEntity, history: History) => void, + onFavourite: (status: StatusEntity) => void, + onReblog: (status: StatusEntity, e?: KeyboardEvent) => void, + onQuote: (status: StatusEntity) => void, + onDelete: (status: StatusEntity) => void, + onDirect: (status: StatusEntity) => void, + onChat: (status: StatusEntity) => void, + onMention: (account: StatusEntity['account'], history: History) => void, + onPin: (status: StatusEntity) => void, + onOpenMedia: (media: ImmutableList, index: number) => void, + onOpenVideo: (media: ImmutableMap | AttachmentEntity, startTime: number) => void, + onOpenAudio: (media: ImmutableMap, startTime: number) => void, + onBlock: (status: StatusEntity) => void, + onEmbed: (status: StatusEntity) => void, + onHeightChange: (status: StatusEntity) => void, + onToggleHidden: (status: StatusEntity) => void, + onShowHoverProfileCard: (status: StatusEntity) => void, + muted: boolean, + hidden: boolean, + unread: boolean, + onMoveUp: (statusId: string, featured: string) => void, + onMoveDown: (statusId: string, featured: string) => void, + getScrollPosition?: () => ScrollPosition | undefined, + updateScrollBottom?: (bottom: number) => void, + cacheMediaWidth: () => void, + cachedMediaWidth: number, + group: ImmutableMap, + displayMedia: string, + allowedEmoji: ImmutableList, + focusable: boolean, + history: History, + featured?: string, +} + +interface IStatusState { + showMedia: boolean, + statusId?: string, + emojiSelectorFocused: boolean, + mediaWrapperWidth?: number, +} + +class Status extends ImmutablePureComponent { static defaultProps = { focusable: true, }; + didShowCard = false; + node?: HTMLDivElement = undefined; + height?: number = undefined; + // Avoid checking props that are functions (and whose equality will always // evaluate to false. See react-immutable-pure-component for usage. - updateOnProps = [ + updateOnProps: any[] = [ 'status', 'account', 'muted', 'hidden', ]; - state = { + state: IStatusState = { showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), statusId: undefined, emojiSelectorFocused: false, }; // Track height changes we know about to compensate scrolling - componentDidMount() { - this.didShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + componentDidMount(): void { + this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); } - getSnapshotBeforeUpdate() { + getSnapshotBeforeUpdate(): ScrollPosition | undefined { if (this.props.getScrollPosition) { return this.props.getScrollPosition(); } else { - return null; + return undefined; } } - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) { + static getDerivedStateFromProps(nextProps: IStatus, prevState: IStatusState) { + if (nextProps.status && nextProps.status.id !== prevState.statusId) { return { showMedia: defaultMediaVisibility(nextProps.status, nextProps.displayMedia), - statusId: nextProps.status.get('id'), + statusId: nextProps.status.id, }; } else { return null; @@ -136,13 +158,13 @@ class Status extends ImmutablePureComponent { } // Compensate height changes - componentDidUpdate(prevProps, prevState, snapshot) { - const doShowCard = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card'); + componentDidUpdate(_prevProps: IStatus, _prevState: IStatusState, snapshot?: ScrollPosition): void { + const doShowCard: boolean = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); if (doShowCard && !this.didShowCard) { this.didShowCard = true; - if (snapshot !== null && this.props.updateScrollBottom) { + if (snapshot && this.props.updateScrollBottom) { if (this.node && this.node.offsetTop < snapshot.top) { this.props.updateScrollBottom(snapshot.height - snapshot.top); } @@ -150,24 +172,26 @@ class Status extends ImmutablePureComponent { } } - componentWillUnmount() { + componentWillUnmount(): void { // FIXME: Run this code only when a status is being deleted. // - // if (this.node && this.props.getScrollPosition) { - // const position = this.props.getScrollPosition(); - // if (position !== null && this.node.offsetTop < position.top) { + // const { getScrollPosition, updateScrollBottom } = this.props; + // + // if (this.node && getScrollPosition && updateScrollBottom) { + // const position = getScrollPosition(); + // if (position && this.node.offsetTop < position.top) { // requestAnimationFrame(() => { - // this.props.updateScrollBottom(position.height - position.top); + // updateScrollBottom(position.height - position.top); // }); // } // } } - handleToggleMediaVisibility = () => { + handleToggleMediaVisibility = (): void => { this.setState({ showMedia: !this.state.showMedia }); } - handleClick = () => { + handleClick = (): void => { if (this.props.onClick) { this.props.onClick(); return; @@ -177,136 +201,139 @@ class Status extends ImmutablePureComponent { return; } - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`); + this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); } - handleExpandClick = (e) => { + handleExpandClick: React.EventHandler = (e) => { if (e.button === 0) { if (!this.props.history) { return; } - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`); + this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); } } - handleExpandedToggle = () => { + handleExpandedToggle = (): void => { this.props.onToggleHidden(this._properStatus()); }; - renderLoadingMediaGallery() { + renderLoadingMediaGallery(): JSX.Element { return
    ; } - renderLoadingVideoPlayer() { + renderLoadingVideoPlayer(): JSX.Element { return
    ; } - renderLoadingAudioPlayer() { + renderLoadingAudioPlayer(): JSX.Element { return
    ; } - handleOpenVideo = (media, startTime) => { + handleOpenVideo = (media: ImmutableMap, startTime: number): void => { this.props.onOpenVideo(media, startTime); } - handleOpenAudio = (media, startTime) => { - this.props.OnOpenAudio(media, startTime); + handleOpenAudio = (media: ImmutableMap, startTime: number): void => { + this.props.onOpenAudio(media, startTime); } - handleHotkeyOpenMedia = e => { + handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { const { onOpenMedia, onOpenVideo } = this.props; const status = this._properStatus(); + const firstAttachment = status.media_attachments.first(); - e.preventDefault(); + e?.preventDefault(); - if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - onOpenVideo(status.getIn(['media_attachments', 0]), 0); + if (firstAttachment) { + if (firstAttachment.type === 'video') { + onOpenVideo(firstAttachment, 0); } else { - onOpenMedia(status.get('media_attachments'), 0); + onOpenMedia(status.media_attachments, 0); } } } - handleHotkeyReply = e => { - e.preventDefault(); + handleHotkeyReply = (e?: KeyboardEvent): void => { + e?.preventDefault(); this.props.onReply(this._properStatus(), this.props.history); } - handleHotkeyFavourite = () => { + handleHotkeyFavourite = (): void => { this.props.onFavourite(this._properStatus()); } - handleHotkeyBoost = e => { + handleHotkeyBoost = (e?: KeyboardEvent): void => { this.props.onReblog(this._properStatus(), e); } - handleHotkeyMention = e => { - e.preventDefault(); - this.props.onMention(this._properStatus().get('account'), this.props.history); + handleHotkeyMention = (e?: KeyboardEvent): void => { + e?.preventDefault(); + this.props.onMention(this._properStatus().account, this.props.history); } - handleHotkeyOpen = () => { - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().get('id')}`); + handleHotkeyOpen = (): void => { + this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); } - handleHotkeyOpenProfile = () => { + handleHotkeyOpenProfile = (): void => { this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}`); } - handleHotkeyMoveUp = e => { - this.props.onMoveUp(this.props.status.get('id'), e.target.getAttribute('data-featured')); + handleHotkeyMoveUp = (e?: KeyboardEvent): void => { + // FIXME: what's going on here? + // this.props.onMoveUp(this.props.status.id, e?.target?.getAttribute('data-featured')); } - handleHotkeyMoveDown = e => { - this.props.onMoveDown(this.props.status.get('id'), e.target.getAttribute('data-featured')); + handleHotkeyMoveDown = (e?: KeyboardEvent): void => { + // FIXME: what's going on here? + // this.props.onMoveDown(this.props.status.id, e?.target?.getAttribute('data-featured')); } - handleHotkeyToggleHidden = () => { + handleHotkeyToggleHidden = (): void => { this.props.onToggleHidden(this._properStatus()); } - handleHotkeyToggleSensitive = () => { + handleHotkeyToggleSensitive = (): void => { this.handleToggleMediaVisibility(); } - handleHotkeyReact = () => { + handleHotkeyReact = (): void => { this._expandEmojiSelector(); } - handleEmojiSelectorExpand = e => { + handleEmojiSelectorExpand: React.EventHandler = e => { if (e.key === 'Enter') { this._expandEmojiSelector(); } e.preventDefault(); } - handleEmojiSelectorUnfocus = () => { + handleEmojiSelectorUnfocus = (): void => { this.setState({ emojiSelectorFocused: false }); } - _expandEmojiSelector = () => { + _expandEmojiSelector = (): void => { this.setState({ emojiSelectorFocused: true }); - const firstEmoji = this.node.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); - firstEmoji.focus(); + const firstEmoji: HTMLDivElement | null | undefined = this.node?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); }; - _properStatus() { + _properStatus(): StatusEntity { const { status } = this.props; - if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - return status.get('reblog'); + if (status.reblog && typeof status.reblog === 'object') { + return status.reblog; } else { return status; } } - handleRef = c => { + handleRef = (c: HTMLDivElement): void => { this.node = c; } - setRef = c => { + setRef = (c: HTMLDivElement): void => { if (c) { this.setState({ mediaWrapperWidth: c.offsetWidth }); } @@ -322,28 +349,26 @@ class Status extends ImmutablePureComponent { // FIXME: why does this need to reassign status and account?? let { status, account, ...other } = this.props; // eslint-disable-line prefer-const - if (status === null) { - return null; - } + if (!status) return null; if (hidden) { return (
    {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {status.get('content')} + {status.content}
    ); } - if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) { - const minHandlers = this.props.muted ? {} : { + if (status.filtered || status.getIn(['reblog', 'filtered'])) { + const minHandlers = this.props.muted ? undefined : { moveUp: this.handleHotkeyMoveUp, moveDown: this.handleHotkeyMoveDown, }; return ( -
    +
    @@ -364,8 +389,8 @@ class Status extends ImmutablePureComponent { ); } - if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - const displayNameHtml = { __html: status.getIn(['account', 'display_name_html']) }; + if (status.reblog && typeof status.reblog === 'object') { + const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) }; reblogElement = ( 0) { + if (size > 0 && firstAttachment) { if (this.props.muted) { media = ( ); - } else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'video') { - const video = status.getIn(['media_attachments', 0]); + } else if (size === 1 && firstAttachment.type === 'video') { + const video = firstAttachment; - if (video.external_video_id && status.card?.html) { + if (video.external_video_id && status.card) { const { mediaWrapperWidth } = this.state; - const height = mediaWrapperWidth / (video.getIn(['meta', 'original', 'width']) / video.getIn(['meta', 'original', 'height'])); + + const getHeight = (): number => { + const width = Number(video.meta.getIn(['original', 'width'])); + const height = Number(video.meta.getIn(['original', 'height'])); + return Number(mediaWrapperWidth) / (width / height); + }; + + const height = getHeight(); + media = (
    @@ -455,17 +490,17 @@ class Status extends ImmutablePureComponent { } else { media = ( - {Component => ( + {(Component: any) => ( ); } - } else if (size === 1 && status.getIn(['media_attachments', 0, 'type']) === 'audio' && status.get('media_attachments').size === 1) { - const attachment = status.getIn(['media_attachments', 0]); + } else if (size === 1 && firstAttachment.type === 'audio') { + const attachment = firstAttachment; media = ( - {Component => ( + {(Component: any) => ( - {Component => ( + {(Component: any) => ( ); } - } else if (status.get('spoiler_text').length === 0 && !status.get('quote') && status.get('card')) { + } else if (status.spoiler_text.length === 0 && !status.quote && status.card) { media = ( ); - } else if (status.get('expectsCard', false)) { + } else if (status.expectsCard) { media = ( ); @@ -532,19 +567,19 @@ class Status extends ImmutablePureComponent { let quote; - if (status.get('quote')) { - if (status.getIn(['pleroma', 'quote_visible'], true) === false) { + if (status.quote) { + if (status.pleroma.get('quote_visible', true) === false) { quote = (

    ); } else { - quote = ; + quote = ; } } - const handlers = this.props.muted ? {} : { + const handlers = this.props.muted ? undefined : { reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, boost: this.handleHotkeyBoost, @@ -559,15 +594,15 @@ class Status extends ImmutablePureComponent { react: this.handleHotkeyReact, }; - const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.get('id')}`; + const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.id}`; // const favicon = status.getIn(['account', 'pleroma', 'favicon']); - // const domain = getDomain(status.get('account')); + // const domain = getDomain(status.account); return (
    - {!group && status.get('group') && ( + {!group && status.group && (
    - Posted in {status.getIn(['group', 'title'])} + Posted in {String(status.getIn(['group', 'title']))}
    )} @@ -613,7 +648,7 @@ class Status extends ImmutablePureComponent { status={status} reblogContent={reblogContent} onClick={this.handleClick} - expanded={!status.get('hidden')} + expanded={!status.hidden} onExpandedToggle={this.handleExpandedToggle} collapsable /> @@ -623,6 +658,7 @@ class Status extends ImmutablePureComponent { {quote} void, + onOpenReblogsModal: (acct: string, statusId: string) => void, + onReply: (status: Status, history: History) => void, + onFavourite: (status: Status) => void, + onBookmark: (status: Status) => void, + onReblog: (status: Status, e: React.MouseEvent) => void, + onQuote: (status: Status, history: History) => void, + onDelete: (status: Status, history: History, redraft?: boolean) => void, + onDirect: (account: any, history: History) => void, + onChat: (account: any, history: History) => void, + onMention: (account: any, history: History) => void, + onMute: (account: any) => void, + onBlock: (status: Status) => void, + onReport: (status: Status) => void, + onEmbed: (status: Status) => void, + onDeactivateUser: (status: Status) => void, + onDeleteUser: (status: Status) => void, + onToggleStatusSensitivity: (status: Status) => void, + onDeleteStatus: (status: Status) => void, + onMuteConversation: (status: Status) => void, + onPin: (status: Status) => void, + withDismiss: boolean, + withGroupAdmin: boolean, + intl: IntlShape, + me: string | null | false | undefined, + isStaff: boolean, + isAdmin: boolean, + allowedEmoji: ImmutableList, + emojiSelectorFocused: boolean, + handleEmojiSelectorUnfocus: () => void, + features: Features, + history: History, + dispatch: Dispatch, +} + +interface IStatusActionBarState { + emojiSelectorVisible: boolean, +} + +class StatusActionBar extends ImmutablePureComponent { - static defaultProps = { + static defaultProps: Partial = { isStaff: false, } + node?: HTMLDivElement = undefined; + state = { emojiSelectorVisible: false, } // Avoid checking props that are functions (and whose equality will always // evaluate to false. See react-immutable-pure-component for usage. + // @ts-ignore: the type checker is wrong. updateOnProps = [ 'status', 'withDismiss', 'emojiSelectorFocused', ] - handleReplyClick = (event) => { + handleReplyClick = () => { const { me, onReply, onOpenUnauthorizedModal, status } = this.props; - event.stopPropagation(); if (me) { onReply(status, this.props.history); @@ -131,18 +140,16 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleShareClick = (e) => { - e.stopPropagation(); - + handleShareClick = () => { navigator.share({ - text: this.props.status.get('search_index'), - url: this.props.status.get('url'), + text: this.props.status.search_index, + url: this.props.status.url, }).catch((e) => { if (e.name !== 'AbortError') console.error(e); }); } - handleLikeButtonHover = e => { + handleLikeButtonHover: React.EventHandler = () => { const { features } = this.props; if (features.emojiReacts && !isUserTouching()) { @@ -150,7 +157,7 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleLikeButtonLeave = e => { + handleLikeButtonLeave: React.EventHandler = () => { const { features } = this.props; if (features.emojiReacts && !isUserTouching()) { @@ -158,51 +165,58 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleLikeButtonClick = e => { + handleLikeButtonClick: React.EventHandler = (e) => { const { features } = this.props; - e.stopPropagation(); - - const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍'; + const reactForStatus = getReactForStatus(this.props.status, this.props.allowedEmoji); + const meEmojiReact = typeof reactForStatus === 'string' ? reactForStatus : '👍'; if (features.emojiReacts && isUserTouching()) { if (this.state.emojiSelectorVisible) { - this.handleReactClick(meEmojiReact)(); + this.handleReact(meEmojiReact); } else { this.setState({ emojiSelectorVisible: true }); } } else { - this.handleReactClick(meEmojiReact)(); + this.handleReact(meEmojiReact); } + + e.stopPropagation(); } - handleReactClick = emoji => { - return e => { - const { me, dispatch, onOpenUnauthorizedModal, status } = this.props; - if (me) { - dispatch(simpleEmojiReact(status, emoji)); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - this.setState({ emojiSelectorVisible: false }); + handleReact = (emoji: string): void => { + const { me, dispatch, onOpenUnauthorizedModal, status } = this.props; + if (me) { + dispatch(simpleEmojiReact(status, emoji) as any); + } else { + onOpenUnauthorizedModal('FAVOURITE'); + } + this.setState({ emojiSelectorVisible: false }); + } + + handleReactClick = (emoji: string): React.EventHandler => { + return () => { + this.handleReact(emoji); }; } - handleFavouriteClick = () => { + handleFavouriteClick: React.EventHandler = (e) => { const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; if (me) { onFavourite(status); } else { onOpenUnauthorizedModal('FAVOURITE'); } + + e.stopPropagation(); } - handleBookmarkClick = (e) => { + handleBookmarkClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onBookmark(this.props.status); } - handleReblogClick = e => { + handleReblogClick: React.EventHandler = e => { const { me, onReblog, onOpenUnauthorizedModal, status } = this.props; e.stopPropagation(); @@ -213,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleQuoteClick = (e) => { + handleQuoteClick: React.EventHandler = (e) => { e.stopPropagation(); const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; if (me) { @@ -223,67 +237,67 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleDeleteClick = (e) => { + handleDeleteClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onDelete(this.props.status, this.props.history); } - handleRedraftClick = (e) => { + handleRedraftClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onDelete(this.props.status, this.props.history, true); } - handlePinClick = (e) => { + handlePinClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onPin(this.props.status); } - handleMentionClick = (e) => { + handleMentionClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onMention(this.props.status.get('account'), this.props.history); + this.props.onMention(this.props.status.account, this.props.history); } - handleDirectClick = (e) => { + handleDirectClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onDirect(this.props.status.get('account'), this.props.history); + this.props.onDirect(this.props.status.account, this.props.history); } - handleChatClick = (e) => { + handleChatClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onChat(this.props.status.get('account'), this.props.history); + this.props.onChat(this.props.status.account, this.props.history); } - handleMuteClick = (e) => { + handleMuteClick: React.EventHandler = (e) => { e.stopPropagation(); - this.props.onMute(this.props.status.get('account')); + this.props.onMute(this.props.status.account); } - handleBlockClick = (e) => { + handleBlockClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onBlock(this.props.status); } - handleOpen = (e) => { + handleOpen: React.EventHandler = (e) => { e.stopPropagation(); - this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.get('id')}`); + this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.id}`); } handleEmbed = () => { this.props.onEmbed(this.props.status); } - handleReport = (e) => { + handleReport: React.EventHandler = (e) => { e.stopPropagation(); this.props.onReport(this.props.status); } - handleConversationMuteClick = (e) => { + handleConversationMuteClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onMuteConversation(this.props.status); } - handleCopy = (e) => { - const url = this.props.status.get('url'); + handleCopy: React.EventHandler = (e) => { + const { url } = this.props.status; const textarea = document.createElement('textarea'); e.stopPropagation(); @@ -303,57 +317,56 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleGroupRemoveAccount = (e) => { - const { status } = this.props; - - e.stopPropagation(); - - this.props.onGroupRemoveAccount(status.getIn(['group', 'id']), status.getIn(['account', 'id'])); - } - - handleGroupRemovePost = (e) => { - const { status } = this.props; - - e.stopPropagation(); - - this.props.onGroupRemoveStatus(status.getIn(['group', 'id']), status.get('id')); - } - - handleDeactivateUser = (e) => { + // handleGroupRemoveAccount: React.EventHandler = (e) => { + // const { status } = this.props; + // + // e.stopPropagation(); + // + // this.props.onGroupRemoveAccount(status.getIn(['group', 'id']), status.getIn(['account', 'id'])); + // } + // + // handleGroupRemovePost: React.EventHandler = (e) => { + // const { status } = this.props; + // + // e.stopPropagation(); + // + // this.props.onGroupRemoveStatus(status.getIn(['group', 'id']), status.id); + // } + + handleDeactivateUser: React.EventHandler = (e) => { e.stopPropagation(); this.props.onDeactivateUser(this.props.status); } - handleDeleteUser = (e) => { + handleDeleteUser: React.EventHandler = (e) => { e.stopPropagation(); this.props.onDeleteUser(this.props.status); } - handleDeleteStatus = (e) => { + handleDeleteStatus: React.EventHandler = (e) => { e.stopPropagation(); this.props.onDeleteStatus(this.props.status); } - handleToggleStatusSensitivity = (e) => { + handleToggleStatusSensitivity: React.EventHandler = (e) => { e.stopPropagation(); this.props.onToggleStatusSensitivity(this.props.status); } - handleOpenReblogsModal = (event) => { + handleOpenReblogsModal = () => { const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props; - event.stopPropagation(); - if (!me) onOpenUnauthorizedModal(); - else onOpenReblogsModal(status.getIn(['account', 'acct']), status.get('id')); + else onOpenReblogsModal(String(status.getIn(['account', 'acct'])), status.id); } - _makeMenu = (publicStatus) => { - const { status, intl, withDismiss, withGroupAdmin, me, features, isStaff, isAdmin } = this.props; - const mutingConversation = status.get('muted'); + _makeMenu = (publicStatus: boolean) => { + const { status, intl, withDismiss, me, features, isStaff, isAdmin } = this.props; + const mutingConversation = status.muted; const ownAccount = status.getIn(['account', 'id']) === me; + const username = String(status.getIn(['account', 'username'])); - const menu = []; + const menu: Menu = []; menu.push({ text: intl.formatMessage(messages.open), @@ -380,9 +393,9 @@ class StatusActionBar extends ImmutablePureComponent { if (features.bookmarks) { menu.push({ - text: intl.formatMessage(status.get('bookmarked') ? messages.unbookmark : messages.bookmark), + text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), action: this.handleBookmarkClick, - icon: require(status.get('bookmarked') ? '@tabler/icons/icons/bookmark-off.svg' : '@tabler/icons/icons/bookmark.svg'), + icon: require(status.bookmarked ? '@tabler/icons/icons/bookmark-off.svg' : '@tabler/icons/icons/bookmark.svg'), }); } @@ -400,14 +413,14 @@ class StatusActionBar extends ImmutablePureComponent { if (ownAccount) { if (publicStatus) { menu.push({ - text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), + text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), action: this.handlePinClick, icon: require(mutingConversation ? '@tabler/icons/icons/pinned-off.svg' : '@tabler/icons/icons/pin.svg'), }); } else { - if (status.get('visibility') === 'private') { + if (status.visibility === 'private') { menu.push({ - text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick, icon: require('@tabler/icons/icons/repeat.svg'), }); @@ -428,20 +441,20 @@ class StatusActionBar extends ImmutablePureComponent { }); } else { menu.push({ - text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.mention, { name: username }), action: this.handleMentionClick, icon: require('@tabler/icons/icons/at.svg'), }); // if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) { // menu.push({ - // text: intl.formatMessage(messages.chat, { name: status.getIn(['account', 'username']) }), + // text: intl.formatMessage(messages.chat, { name: username }), // action: this.handleChatClick, // icon: require('@tabler/icons/icons/messages.svg'), // }); // } else { // menu.push({ - // text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), + // text: intl.formatMessage(messages.direct, { name: username }), // action: this.handleDirectClick, // icon: require('@tabler/icons/icons/mail.svg'), // }); @@ -449,17 +462,17 @@ class StatusActionBar extends ImmutablePureComponent { menu.push(null); menu.push({ - text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.mute, { name: username }), action: this.handleMuteClick, icon: require('@tabler/icons/icons/circle-x.svg'), }); menu.push({ - text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.block, { name: username }), action: this.handleBlockClick, icon: require('@tabler/icons/icons/ban.svg'), }); menu.push({ - text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.report, { name: username }), action: this.handleReport, icon: require('@tabler/icons/icons/flag.svg'), }); @@ -470,33 +483,33 @@ class StatusActionBar extends ImmutablePureComponent { if (isAdmin) { menu.push({ - text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.admin_account, { name: username }), href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, icon: require('@tabler/icons/icons/gavel.svg'), action: (event) => event.stopPropagation(), }); menu.push({ text: intl.formatMessage(messages.admin_status), - href: `/pleroma/admin/#/statuses/${status.get('id')}/`, + href: `/pleroma/admin/#/statuses/${status.id}/`, icon: require('@tabler/icons/icons/pencil.svg'), action: (event) => event.stopPropagation(), }); } menu.push({ - text: intl.formatMessage(status.get('sensitive') === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), + text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), action: this.handleToggleStatusSensitivity, icon: require('@tabler/icons/icons/alert-triangle.svg'), }); if (!ownAccount) { menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.deactivateUser, { name: username }), action: this.handleDeactivateUser, icon: require('@tabler/icons/icons/user-off.svg'), }); menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: status.getIn(['account', 'username']) }), + text: intl.formatMessage(messages.deleteUser, { name: username }), action: this.handleDeleteUser, icon: require('@tabler/icons/icons/user-minus.svg'), destructive: true, @@ -510,223 +523,194 @@ class StatusActionBar extends ImmutablePureComponent { } } - if (!ownAccount && withGroupAdmin) { - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.group_remove_account), - action: this.handleGroupRemoveAccount, - icon: require('@tabler/icons/icons/user-x.svg'), - destructive: true, - }); - menu.push({ - text: intl.formatMessage(messages.group_remove_post), - action: this.handleGroupRemovePost, - icon: require('@tabler/icons/icons/trash.svg'), - destructive: true, - }); - } + // if (!ownAccount && withGroupAdmin) { + // menu.push(null); + // menu.push({ + // text: intl.formatMessage(messages.group_remove_account), + // action: this.handleGroupRemoveAccount, + // icon: require('@tabler/icons/icons/user-x.svg'), + // destructive: true, + // }); + // menu.push({ + // text: intl.formatMessage(messages.group_remove_post), + // action: this.handleGroupRemovePost, + // icon: require('@tabler/icons/icons/trash.svg'), + // destructive: true, + // }); + // } return menu; } - setRef = c => { + setRef = (c: HTMLDivElement) => { this.node = c; } componentDidMount() { - document.addEventListener('click', e => { - if (this.node && !this.node.contains(e.target)) + document.addEventListener('click', (e) => { + if (this.node && !this.node.contains(e.target as Node)) this.setState({ emojiSelectorVisible: false }); }); } render() { - const { status, intl, allowedEmoji, features, me } = this.props; + const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.visibility); - const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const replyCount = status.replies_count; + const reblogCount = status.reblogs_count; + const favouriteCount = status.favourites_count; - const replyCount = status.get('replies_count'); - const reblogCount = status.get('reblogs_count'); - const favouriteCount = status.get('favourites_count'); const emojiReactCount = reduceEmoji( - status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()), + (status.getIn(['pleroma', 'emoji_reactions']) || ImmutableList()) as ImmutableList, favouriteCount, - status.get('favourited'), + status.favourited, allowedEmoji, ).reduce((acc, cur) => acc + cur.get('count'), 0); - const meEmojiReact = getReactForStatus(status, allowedEmoji); - const meEmojiTitle = intl.formatMessage({ + + const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; + + const reactMessages = { '👍': messages.reactionLike, '❤️': messages.reactionHeart, '😆': messages.reactionLaughing, '😮': messages.reactionOpenMouth, '😢': messages.reactionCry, '😩': messages.reactionWeary, - }[meEmojiReact] || messages.favourite); + }; + + const meEmojiTitle = intl.formatMessage(meEmojiReact ? reactMessages[meEmojiReact] : messages.favourite); const menu = this._makeMenu(publicStatus); let reblogIcon = require('@tabler/icons/icons/repeat.svg'); let replyTitle; - if (status.get('visibility') === 'direct') { + if (status.visibility === 'direct') { reblogIcon = require('@tabler/icons/icons/mail.svg'); - } else if (status.get('visibility') === 'private') { + } else if (status.visibility === 'private') { reblogIcon = require('@tabler/icons/icons/lock.svg'); } - let reblogButton; - - if (me && features.quotePosts) { - const reblogMenu = [ - { - text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog), - action: this.handleReblogClick, - icon: require('@tabler/icons/icons/repeat.svg'), - }, - { - text: intl.formatMessage(messages.quotePost), - action: this.handleQuoteClick, - icon: require('@tabler/icons/icons/quote.svg'), - }, - ]; - - reblogButton = ( - - ); - } else { - reblogButton = ( - - ); - } + const reblogMenu = [{ + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), + action: this.handleReblogClick, + icon: require('@tabler/icons/icons/repeat.svg'), + }, { + text: intl.formatMessage(messages.quotePost), + action: this.handleQuoteClick, + icon: require('@tabler/icons/icons/quote.svg'), + }]; + + const reblogButton = ( + + ); - if (status.get('in_reply_to_id', null) === null) { + if (!status.in_reply_to_id) { replyTitle = intl.formatMessage(messages.reply); } else { replyTitle = intl.formatMessage(messages.replyAll); } - const canShare = ('share' in navigator) && status.get('visibility') === 'public'; - - const shareButton = canShare && ( -
    - -
    - ); + const canShare = ('share' in navigator) && status.visibility === 'public'; return (
    -
    - + + {features.quotePosts && me ? ( + + {reblogButton} + + ) : ( + reblogButton + )} + + {features.emojiReacts ? ( + + )} + > + + + ): ( + + )} + + {canShare && ( + + )} - {replyCount !== 0 ? ( - - {replyCount} - - ) : null} -
    - -
    - {reblogButton} - {reblogCount !== 0 && {reblogCount}} -
    - -
    - {/* */} - + - {emojiReactCount !== 0 && ( - (features.exposableReactions && !features.emojiReacts) ? ( - - {emojiReactCount} - - ) : ( - {emojiReactCount} - ) - )} -
    - - {shareButton} - -
    - -
    +
    ); } } -const mapStateToProps = state => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); - const instance = state.get('instance'); +const mapStateToProps = (state: RootState) => { + const { me, instance } = state; + const account = state.accounts.get(me); return { me, - isStaff: account ? isStaff(account) : false, - isAdmin: account ? isAdmin(account) : false, + isStaff: account ? account.staff : false, + isAdmin: account ? account.admin : false, features: getFeatures(instance), }; }; -const mapDispatchToProps = (dispatch, { status }) => ({ +const mapDispatchToProps = (dispatch: Dispatch, { status }: { status: Status}) => ({ dispatch, - onOpenUnauthorizedModal(action) { + onOpenUnauthorizedModal(action: AnyAction) { dispatch(openModal('UNAUTHORIZED', { action, - ap_id: status.get('url'), + ap_id: status.url, })); }, - onOpenReblogsModal(username, statusId) { + onOpenReblogsModal(username: string, statusId: string) { dispatch(openModal('REBLOGS', { username, statusId, @@ -734,6 +718,9 @@ const mapDispatchToProps = (dispatch, { status }) => ({ }, }); +// @ts-ignore export default withRouter(injectIntl( + // @ts-ignore connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true }, + // @ts-ignore )(StatusActionBar))); diff --git a/app/soapbox/components/thumb_navigation.tsx b/app/soapbox/components/thumb_navigation.tsx index 7e1debc61..0af978943 100644 --- a/app/soapbox/components/thumb_navigation.tsx +++ b/app/soapbox/components/thumb_navigation.tsx @@ -55,9 +55,9 @@ const ThumbNavigation: React.FC = (): JSX.Element => { paths={['/messages', '/conversations']} /> ) - )} + )} - {/* (account && isStaff(account)) && ( + {/* (account && account.staff && ( } diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx new file mode 100644 index 000000000..aaa5b9349 --- /dev/null +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Emoji, HStack } from 'soapbox/components/ui'; + +interface IEmojiButton { + emoji: string, + onClick: React.EventHandler, + className?: string, + tabIndex?: number, +} + +const EmojiButton: React.FC = ({ emoji, className, onClick, tabIndex }): JSX.Element => { + return ( + + ); +}; + +interface IEmojiSelector { + emojis: string[], + onReact: (emoji: string) => void, + visible?: boolean, + focused?: boolean, +} + +const EmojiSelector: React.FC = ({ emojis, onReact, visible = false, focused = false }): JSX.Element => { + + const handleReact = (emoji: string): React.EventHandler => { + return (e) => { + onReact(emoji); + e.preventDefault(); + e.stopPropagation(); + }; + }; + + return ( + + {emojis.map((emoji, i) => ( + + ))} + + ); +}; + +export default EmojiSelector; diff --git a/app/soapbox/components/ui/emoji/emoji.tsx b/app/soapbox/components/ui/emoji/emoji.tsx new file mode 100644 index 000000000..9bbaa0dec --- /dev/null +++ b/app/soapbox/components/ui/emoji/emoji.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { joinPublicPath } from 'soapbox/utils/static'; + +// Taken from twemoji-parser +// https://github.com/twitter/twemoji-parser/blob/a97ef3994e4b88316812926844d51c296e889f76/src/index.js +const removeVS16s = (rawEmoji: string): string => { + const vs16RegExp = /\uFE0F/g; + const zeroWidthJoiner = String.fromCharCode(0x200d); + return rawEmoji.indexOf(zeroWidthJoiner) < 0 ? rawEmoji.replace(vs16RegExp, '') : rawEmoji; +}; + +const toCodePoints = (unicodeSurrogates: string): string[] => { + const points = []; + let char = 0; + let previous = 0; + let i = 0; + while (i < unicodeSurrogates.length) { + char = unicodeSurrogates.charCodeAt(i++); + if (previous) { + points.push((0x10000 + ((previous - 0xd800) << 10) + (char - 0xdc00)).toString(16)); + previous = 0; + } else if (char > 0xd800 && char <= 0xdbff) { + previous = char; + } else { + points.push(char.toString(16)); + } + } + return points; +}; + +interface IEmoji { + className?: string, + emoji: string, +} + +const Emoji: React.FC = ({ className, emoji }): JSX.Element | null => { + const codepoints = toCodePoints(removeVS16s(emoji)); + const filename = codepoints.join('-'); + + if (!filename) return null; + + return ( + {emoji} + ); +}; + +export default Emoji; diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 467c8e4cd..291b0b4f8 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -4,15 +4,10 @@ import InlineSVG from 'react-inlinesvg'; import Text from '../text/text'; -interface IIconButton { - alt?: string, - className?: string, +interface IIconButton extends React.ButtonHTMLAttributes { iconClassName?: string, - disabled?: boolean, src: string, - onClick?: () => void, text?: string, - title?: string, transparent?: boolean } diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 302738ed9..fa60595f5 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -2,6 +2,8 @@ export { default as Avatar } from './avatar/avatar'; export { default as Button } from './button/button'; export { Card, CardBody, CardHeader, CardTitle } from './card/card'; export { default as Column } from './column/column'; +export { default as Emoji } from './emoji/emoji'; +export { default as EmojiSelector } from './emoji-selector/emoji-selector'; export { default as Form } from './form/form'; export { default as FormActions } from './form-actions/form-actions'; export { default as FormGroup } from './form-group/form-group'; diff --git a/app/soapbox/containers/dropdown_menu_container.js b/app/soapbox/containers/dropdown_menu_container.js deleted file mode 100644 index 57f4d5468..000000000 --- a/app/soapbox/containers/dropdown_menu_container.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; - -import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; -import { openModal, closeModal } from '../actions/modals'; -import DropdownMenu from '../components/dropdown_menu'; -import { isUserTouching } from '../is_mobile'; - -const mapStateToProps = state => ({ - isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'), - dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), - openDropdownId: state.getIn(['dropdown_menu', 'openId']), - openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), -}); - -const mapDispatchToProps = (dispatch, { status, items }) => ({ - onOpen(id, onItemClick, dropdownPlacement, keyboard) { - dispatch(isUserTouching() ? openModal('ACTIONS', { - status, - actions: items, - onClick: onItemClick, - }) : openDropdownMenu(id, dropdownPlacement, keyboard)); - }, - onClose(id) { - dispatch(closeModal('ACTIONS')); - dispatch(closeDropdownMenu(id)); - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/soapbox/containers/dropdown_menu_container.ts b/app/soapbox/containers/dropdown_menu_container.ts new file mode 100644 index 000000000..fa149d0f4 --- /dev/null +++ b/app/soapbox/containers/dropdown_menu_container.ts @@ -0,0 +1,38 @@ +import { connect } from 'react-redux'; + +import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu'; +import { openModal, closeModal } from '../actions/modals'; +import DropdownMenu from '../components/dropdown_menu'; +import { isUserTouching } from '../is_mobile'; + +import type { Dispatch } from 'redux'; +import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown_menu'; +import type { RootState } from 'soapbox/store'; + +const mapStateToProps = (state: RootState) => ({ + isModalOpen: Boolean(state.modals.size && state.modals.last().modalType === 'ACTIONS'), + dropdownPlacement: state.dropdown_menu.get('placement'), + openDropdownId: state.dropdown_menu.get('openId'), + openedViaKeyboard: state.dropdown_menu.get('keyboard'), +}); + +const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial) => ({ + onOpen( + id: number, + onItemClick: React.EventHandler, + dropdownPlacement: DropdownPlacement, + keyboard: boolean, + ) { + dispatch(isUserTouching() ? openModal('ACTIONS', { + status, + actions: items, + onClick: onItemClick, + }) : openDropdownMenu(id, dropdownPlacement, keyboard)); + }, + onClose(id: number) { + dispatch(closeModal('ACTIONS')); + dispatch(closeDropdownMenu(id)); + }, +}); + +export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index 65c2160c0..9ccbd204f 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -18,12 +18,8 @@ import StillImage from 'soapbox/components/still_image'; import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider } from 'soapbox/components/ui'; import ActionButton from 'soapbox/features/ui/components/action_button'; import { - isStaff, - isAdmin, - isModerator, isLocal, isRemote, - getDomain, } from 'soapbox/utils/accounts'; import { getFeatures } from 'soapbox/utils/features'; @@ -322,7 +318,7 @@ class Header extends ImmutablePureComponent { } if (isRemote(account)) { - const domain = getDomain(account); + const domain = account.fqn.split('@')[1]; menu.push(null); @@ -341,10 +337,10 @@ class Header extends ImmutablePureComponent { } } - if (isStaff(meAccount)) { + if (meAccount.staff) { menu.push(null); - if (isAdmin(meAccount)) { + if (meAccount.admin) { menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), to: `/pleroma/admin/#/users/${account.id}/`, @@ -353,8 +349,8 @@ class Header extends ImmutablePureComponent { }); } - if (account.get('id') !== me && isLocal(account) && isAdmin(meAccount)) { - if (isAdmin(account)) { + if (account.id !== me && isLocal(account) && meAccount.admin) { + if (account.admin) { menu.push({ text: intl.formatMessage(messages.demoteToModerator, { name: account.get('username') }), action: this.props.onPromoteToModerator, @@ -365,7 +361,7 @@ class Header extends ImmutablePureComponent { action: this.props.onDemoteToUser, icon: require('@tabler/icons/icons/arrow-down-circle.svg'), }); - } else if (isModerator(account)) { + } else if (account.moderator) { menu.push({ text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }), action: this.props.onPromoteToAdmin, @@ -404,7 +400,7 @@ class Header extends ImmutablePureComponent { }); } - if (features.suggestionsV2 && isAdmin(meAccount)) { + if (features.suggestionsV2 && meAccount.admin) { if (account.getIn(['pleroma', 'is_suggested'])) { menu.push({ text: intl.formatMessage(messages.unsuggestUser, { name: account.get('username') }), diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index 6087217ca..6b4ff5e6a 100644 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ b/app/soapbox/features/account_timeline/containers/header_container.js @@ -37,7 +37,6 @@ import { initReport } from 'soapbox/actions/reports'; import { getSettings } from 'soapbox/actions/settings'; import snackbar from 'soapbox/actions/snackbar'; import { makeGetAccount } from 'soapbox/selectors'; -import { isAdmin } from 'soapbox/utils/accounts'; import Header from '../components/header'; @@ -216,7 +215,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onPromoteToModerator(account) { - const messageType = isAdmin(account) ? messages.demotedToModerator : messages.promotedToModerator; + const messageType = account.admin ? messages.demotedToModerator : messages.promotedToModerator; const message = intl.formatMessage(messageType, { acct: account.get('acct') }); dispatch(promoteToModerator(account.get('id'))) diff --git a/app/soapbox/features/admin/index.js b/app/soapbox/features/admin/index.js index 9ca0264a4..09f626f0e 100644 --- a/app/soapbox/features/admin/index.js +++ b/app/soapbox/features/admin/index.js @@ -7,7 +7,6 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/email_list'; -import { isAdmin } from 'soapbox/utils/accounts'; import sourceCode from 'soapbox/utils/code'; import { parseVersion } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features'; @@ -139,7 +138,7 @@ class Dashboard extends ImmutablePureComponent {
    - {isAdmin(account) && } + {account.admin && }

    @@ -148,7 +147,7 @@ class Dashboard extends ImmutablePureComponent {
  • {v.software} {v.version}
  • - {supportsEmailList && isAdmin(account) &&
    + {supportsEmailList && account.admin &&

    • subscribers.csv
    • diff --git a/app/soapbox/features/status/components/action_bar.js b/app/soapbox/features/status/components/action_bar.js index b3af67ca2..929a20b53 100644 --- a/app/soapbox/features/status/components/action_bar.js +++ b/app/soapbox/features/status/components/action_bar.js @@ -7,7 +7,6 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { isUserTouching } from 'soapbox/is_mobile'; -import { isStaff, isAdmin } from 'soapbox/utils/accounts'; import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; import { getFeatures } from 'soapbox/utils/features'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; @@ -65,8 +64,8 @@ const mapStateToProps = state => { return { me, - isStaff: account ? isStaff(account) : false, - isAdmin: account ? isAdmin(account) : false, + isStaff: account ? account.staff : false, + isAdmin: account ? account.admin : false, features: getFeatures(instance), }; }; diff --git a/app/soapbox/features/ui/components/instance_moderation_panel.js b/app/soapbox/features/ui/components/instance_moderation_panel.js index ad54c4db1..2f18ba887 100644 --- a/app/soapbox/features/ui/components/instance_moderation_panel.js +++ b/app/soapbox/features/ui/components/instance_moderation_panel.js @@ -11,7 +11,6 @@ import { openModal } from 'soapbox/actions/modals'; import DropdownMenu from 'soapbox/containers/dropdown_menu_container'; import InstanceRestrictions from 'soapbox/features/federation_restrictions/components/instance_restrictions'; import { makeGetRemoteInstance } from 'soapbox/selectors'; -import { isAdmin } from 'soapbox/utils/accounts'; const getRemoteInstance = makeGetRemoteInstance(); @@ -20,13 +19,13 @@ const messages = defineMessages({ }); const mapStateToProps = (state, { host }) => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); + const { me, instance } = state; + const account = state.accounts.get(me); return { - instance: state.get('instance'), + instance, remoteInstance: getRemoteInstance(state, host), - isAdmin: isAdmin(account), + isAdmin: account.admin, }; }; diff --git a/app/soapbox/features/ui/components/profile-dropdown.tsx b/app/soapbox/features/ui/components/profile-dropdown.tsx index 0887afe61..b69f19314 100644 --- a/app/soapbox/features/ui/components/profile-dropdown.tsx +++ b/app/soapbox/features/ui/components/profile-dropdown.tsx @@ -9,7 +9,6 @@ import { fetchOwnAccounts } from 'soapbox/actions/auth'; import { Menu, MenuButton, MenuDivider, MenuItem, MenuLink, MenuList } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; -import { isStaff } from 'soapbox/utils/accounts'; import Account from '../../../components/account'; @@ -31,7 +30,7 @@ type IMenuItem = { action?: (event: React.MouseEvent) => void } -const getAccount: any = makeGetAccount(); +const getAccount = makeGetAccount(); const ProfileDropdown: React.FC = ({ account, children }) => { const dispatch = useDispatch(); @@ -40,7 +39,7 @@ const ProfileDropdown: React.FC = ({ account, children }) => { const me = useAppSelector((state) => state.me); const currentAccount = useAppSelector((state) => getAccount(state, me)); const authUsers = useAppSelector((state) => state.auth.get('users')); - const isCurrentAccountStaff = isStaff(currentAccount) || false; + const isCurrentAccountStaff = Boolean(currentAccount?.staff); const otherAccounts = useAppSelector((state) => authUsers.map((authUser: any) => getAccount(state, authUser.get('id')))); const handleLogOut = () => { diff --git a/app/soapbox/features/ui/components/profile_info_panel.js b/app/soapbox/features/ui/components/profile_info_panel.js index 1f57ed483..ce89ee6cb 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.js +++ b/app/soapbox/features/ui/components/profile_info_panel.js @@ -12,7 +12,7 @@ import { initAccountNoteModal } from 'soapbox/actions/account_notes'; import Badge from 'soapbox/components/badge'; import { Icon, HStack, Stack, Text } from 'soapbox/components/ui'; import VerificationBadge from 'soapbox/components/verification_badge'; -import { getAcct, isAdmin, isModerator, isLocal } from 'soapbox/utils/accounts'; +import { isLocal } from 'soapbox/utils/accounts'; import { displayFqn } from 'soapbox/utils/state'; import ProfileStats from './profile_stats'; @@ -48,9 +48,9 @@ class ProfileInfoPanel extends ImmutablePureComponent { getStaffBadge = () => { const { account } = this.props; - if (isAdmin(account)) { + if (account?.admin) { return ; - } else if (isModerator(account)) { + } else if (account?.moderator) { return ; } else { return null; @@ -155,7 +155,7 @@ class ProfileInfoPanel extends ImmutablePureComponent { {verified && } - {account.get('bot') && } + {account.bot && } {badges.length > 0 && ( @@ -166,7 +166,7 @@ class ProfileInfoPanel extends ImmutablePureComponent { - @{getAcct(account, displayFqn)} + @{displayFqn ? account.fqn : account.acct} {account.get('locked') && ( diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 400cac95a..465502846 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -26,7 +26,6 @@ import HomePage from 'soapbox/pages/home_page'; import ProfilePage from 'soapbox/pages/profile_page'; import RemoteInstancePage from 'soapbox/pages/remote_instance_page'; import StatusPage from 'soapbox/pages/status_page'; -import { isStaff, isAdmin } from 'soapbox/utils/accounts'; import { getAccessToken } from 'soapbox/utils/auth'; import { getVapidKey } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; @@ -495,12 +494,12 @@ class UI extends React.PureComponent { dispatch(fetchChats()); } - if (isStaff(account)) { + if (account.staff) { dispatch(fetchReports({ state: 'open' })); dispatch(fetchUsers(['local', 'need_approval'])); } - if (isAdmin(account)) { + if (account.admin) { dispatch(fetchConfig()); } diff --git a/app/soapbox/features/ui/util/react_router_helpers.js b/app/soapbox/features/ui/util/react_router_helpers.js index 295e20019..8a7e7ed87 100644 --- a/app/soapbox/features/ui/util/react_router_helpers.js +++ b/app/soapbox/features/ui/util/react_router_helpers.js @@ -5,7 +5,6 @@ import { connect } from 'react-redux'; import { Redirect, Route } from 'react-router-dom'; import { getSettings } from 'soapbox/actions/settings'; -import { isStaff, isAdmin } from 'soapbox/utils/accounts'; import BundleColumnError from '../components/bundle_column_error'; import ColumnForbidden from '../components/column_forbidden'; @@ -111,8 +110,8 @@ class WrappedRoute extends React.Component { const authorized = [ account || publicRoute, developerOnly ? settings.get('isDeveloper') : true, - staffOnly ? account && isStaff(account) : true, - adminOnly ? account && isAdmin(account) : true, + staffOnly ? account && account.staff : true, + adminOnly ? account && account.admin : true, ].every(c => c); if (!authorized) { diff --git a/app/soapbox/normalizers/__tests__/account-test.js b/app/soapbox/normalizers/__tests__/account-test.js index 8d12a935d..1735d0628 100644 --- a/app/soapbox/normalizers/__tests__/account-test.js +++ b/app/soapbox/normalizers/__tests__/account-test.js @@ -168,4 +168,13 @@ describe('normalizeAccount()', () => { expect(result.fqn).toEqual('benis911@mastodon.social'); }); + + it('normalizes Pleroma staff', () => { + const account = require('soapbox/__fixtures__/pleroma-account.json'); + const result = normalizeAccount(account); + + expect(result.admin).toBe(true); + expect(result.staff).toBe(true); + expect(result.moderator).toBe(false); + }); }); diff --git a/app/soapbox/normalizers/account.ts b/app/soapbox/normalizers/account.ts index 333e151a8..679dd770d 100644 --- a/app/soapbox/normalizers/account.ts +++ b/app/soapbox/normalizers/account.ts @@ -13,7 +13,6 @@ import { import emojify from 'soapbox/features/emoji/emoji'; import { normalizeEmoji } from 'soapbox/normalizers/emoji'; -import { acctFull } from 'soapbox/utils/accounts'; import { unescapeHTML } from 'soapbox/utils/html'; import { mergeDefined, makeEmojiMap } from 'soapbox/utils/normalizers'; @@ -39,7 +38,7 @@ export const AccountRecord = ImmutableRecord({ last_status_at: new Date(), location: '', locked: false, - moved: null as EmbeddedEntity | null, + moved: null as EmbeddedEntity, note: '', pleroma: ImmutableMap(), source: ImmutableMap(), @@ -51,12 +50,15 @@ export const AccountRecord = ImmutableRecord({ verified: false, // Internal fields + admin: false, display_name_html: '', + moderator: false, note_emojified: '', note_plain: '', patron: ImmutableMap(), relationship: ImmutableList>(), should_refetch: false, + staff: false, }); // https://docs.joinmastodon.org/entities/field/ @@ -197,8 +199,41 @@ const addInternalFields = (account: ImmutableMap) => { }); }; +const getDomainFromURL = (account: ImmutableMap): string => { + try { + const url = account.get('url'); + return new URL(url).host; + } catch { + return ''; + } +}; + +export const guessFqn = (account: ImmutableMap): string => { + const acct = account.get('acct', ''); + const [user, domain] = acct.split('@'); + + if (domain) { + return acct; + } else { + return [user, getDomainFromURL(account)].join('@'); + } +}; + const normalizeFqn = (account: ImmutableMap) => { - return account.set('fqn', acctFull(account)); + const fqn = account.get('fqn') || guessFqn(account); + return account.set('fqn', fqn); +}; + +const addStaffFields = (account: ImmutableMap) => { + const admin = account.getIn(['pleroma', 'is_admin']) === true; + const moderator = account.getIn(['pleroma', 'is_moderator']) === true; + const staff = admin || moderator; + + return account.merge({ + admin, + moderator, + staff, + }); }; export const normalizeAccount = (account: Record) => { @@ -213,6 +248,7 @@ export const normalizeAccount = (account: Record) => { normalizeBirthday(account); normalizeLocation(account); normalizeFqn(account); + addStaffFields(account); fixUsername(account); fixDisplayName(account); addInternalFields(account); diff --git a/app/soapbox/normalizers/attachment.ts b/app/soapbox/normalizers/attachment.ts index de960d258..06fba73ad 100644 --- a/app/soapbox/normalizers/attachment.ts +++ b/app/soapbox/normalizers/attachment.ts @@ -26,8 +26,8 @@ export const AttachmentRecord = ImmutableRecord({ // Internal fields // TODO: Remove these? They're set in selectors/index.js - account: null, - status: null, + account: null as any, + status: null as any, }); // Ensure attachments have required fields diff --git a/app/soapbox/normalizers/notification.ts b/app/soapbox/normalizers/notification.ts index defd51215..ea2f922e9 100644 --- a/app/soapbox/normalizers/notification.ts +++ b/app/soapbox/normalizers/notification.ts @@ -9,16 +9,30 @@ import { fromJS, } from 'immutable'; +import type { Account, Status, EmbeddedEntity } from 'soapbox/types/entities'; + +type NotificationType = '' + | 'follow' + | 'follow_request' + | 'mention' + | 'reblog' + | 'favourite' + | 'poll' + | 'status' + | 'move' + | 'pleroma:chat_mention' + | 'pleroma:emoji_reaction'; + // https://docs.joinmastodon.org/entities/notification/ export const NotificationRecord = ImmutableRecord({ - account: null, - chat_message: null, // pleroma:chat_mention + account: null as EmbeddedEntity, + chat_message: null as ImmutableMap | string | null, // pleroma:chat_mention created_at: new Date(), - emoji: null, // pleroma:emoji_reaction + emoji: null as string | null, // pleroma:emoji_reaction id: '', - status: null, - target: null, // move - type: '', + status: null as EmbeddedEntity, + target: null as EmbeddedEntity, // move + type: '' as NotificationType, }); export const normalizeNotification = (notification: Record) => { diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index c69354c79..8f1c27c1e 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -16,21 +16,23 @@ import { normalizeEmoji } from 'soapbox/normalizers/emoji'; import { normalizeMention } from 'soapbox/normalizers/mention'; import { normalizePoll } from 'soapbox/normalizers/poll'; +import type { ReducerAccount } from 'soapbox/reducers/accounts'; import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities'; type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct'; // https://docs.joinmastodon.org/entities/status/ export const StatusRecord = ImmutableRecord({ - account: null as EmbeddedEntity, + account: null as EmbeddedEntity, application: null as ImmutableMap | null, bookmarked: false, - card: null as EmbeddedEntity, + card: null as Card | null, content: '', created_at: new Date(), emojis: ImmutableList(), favourited: false, favourites_count: 0, + group: null as EmbeddedEntity, in_reply_to_account_id: null as string | null, in_reply_to_id: null as string | null, id: '', @@ -55,6 +57,7 @@ export const StatusRecord = ImmutableRecord({ // Internal fields contentHtml: '', + expectsCard: false, filtered: false, hidden: false, search_index: '', diff --git a/app/soapbox/pages/remote_instance_page.js b/app/soapbox/pages/remote_instance_page.js index 1733d7738..c922be6a5 100644 --- a/app/soapbox/pages/remote_instance_page.js +++ b/app/soapbox/pages/remote_instance_page.js @@ -11,19 +11,18 @@ import { InstanceInfoPanel, InstanceModerationPanel, } from 'soapbox/features/ui/util/async-components'; -import { isAdmin } from 'soapbox/utils/accounts'; import { federationRestrictionsDisclosed } from 'soapbox/utils/state'; import { Layout } from '../components/ui'; const mapStateToProps = state => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); + const me = state.me; + const account = state.accounts.get(me); return { me, disclosed: federationRestrictionsDisclosed(state), - isAdmin: isAdmin(account), + isAdmin: Boolean(account?.admin), }; }; diff --git a/app/soapbox/reducers/accounts.ts b/app/soapbox/reducers/accounts.ts index 444d7ac97..781385858 100644 --- a/app/soapbox/reducers/accounts.ts +++ b/app/soapbox/reducers/accounts.ts @@ -45,14 +45,18 @@ type AccountMap = ImmutableMap; type APIEntity = Record; type APIEntities = Array; -type State = ImmutableMap; +export interface ReducerAccount extends AccountRecord { + moved: string | null, +} + +type State = ImmutableMap; const initialState: State = ImmutableMap(); -const minifyAccount = (account: AccountRecord): AccountRecord => { +const minifyAccount = (account: AccountRecord): ReducerAccount => { return account.mergeWith((o, n) => n || o, { moved: normalizeId(account.getIn(['moved', 'id'])), - }); + }) as ReducerAccount; }; const fixAccount = (state: State, account: APIEntity) => { @@ -194,9 +198,9 @@ const importAdminUser = (state: State, adminUser: ImmutableMap): St const account = state.get(id); if (!account) { - return state.set(id, buildAccount(adminUser)); + return state.set(id, minifyAccount(buildAccount(adminUser))); } else { - return state.set(id, mergeAdminUser(account, adminUser)); + return state.set(id, minifyAccount(mergeAdminUser(account, adminUser))); } }; @@ -223,7 +227,7 @@ export default function accounts(state: State = initialState, action: AnyAction) case ACCOUNTS_IMPORT: return normalizeAccounts(state, action.accounts); case ACCOUNT_FETCH_FAIL_FOR_USERNAME_LOOKUP: - return state.set(-1, normalizeAccount({ username: action.username })); + return fixAccount(state, { id: -1, username: action.username }); case CHATS_FETCH_SUCCESS: case CHATS_EXPAND_SUCCESS: return importAccountsFromChats(state, action.chats); diff --git a/app/soapbox/reducers/admin.js b/app/soapbox/reducers/admin.ts similarity index 54% rename from app/soapbox/reducers/admin.js rename to app/soapbox/reducers/admin.ts index 6a75e2c5b..90cb10a4f 100644 --- a/app/soapbox/reducers/admin.js +++ b/app/soapbox/reducers/admin.ts @@ -21,33 +21,54 @@ import { ADMIN_USERS_APPROVE_SUCCESS, } from '../actions/admin'; +import type { AnyAction } from 'redux'; +import type { Config } from 'soapbox/utils/config_db'; + const ReducerRecord = ImmutableRecord({ - reports: ImmutableMap(), - openReports: ImmutableOrderedSet(), - users: ImmutableMap(), - latestUsers: ImmutableOrderedSet(), - awaitingApproval: ImmutableOrderedSet(), - configs: ImmutableList(), + reports: ImmutableMap(), + openReports: ImmutableOrderedSet(), + users: ImmutableMap(), + latestUsers: ImmutableOrderedSet(), + awaitingApproval: ImmutableOrderedSet(), + configs: ImmutableList(), needsReboot: false, }); -const FILTER_UNAPPROVED = ['local', 'need_approval']; -const FILTER_LATEST = ['local', 'active']; +type State = ReturnType; + +// Umm... based? +// https://itnext.io/typescript-extract-unpack-a-type-from-a-generic-baca7af14e51 +type InnerRecord = R extends ImmutableRecord ? TProps : never; + +type InnerState = InnerRecord; + +// Lol https://javascript.plainenglish.io/typescript-essentials-conditionally-filter-types-488705bfbf56 +type FilterConditionally = Pick; + +type SetKeys = keyof FilterConditionally>; + +type APIReport = { id: string, state: string, statuses: any[] }; +type APIUser = { id: string, email: string, nickname: string, registration_reason: string }; + +type Filter = 'local' | 'need_approval' | 'active'; -const filtersMatch = (f1, f2) => is(ImmutableSet(f1), ImmutableSet(f2)); -const toIds = items => items.map(item => item.id); +const FILTER_UNAPPROVED: Filter[] = ['local', 'need_approval']; +const FILTER_LATEST: Filter[] = ['local', 'active']; -const mergeSet = (state, key, users) => { +const filtersMatch = (f1: string[], f2: string[]) => is(ImmutableSet(f1), ImmutableSet(f2)); +const toIds = (items: any[]) => items.map(item => item.id); + +const mergeSet = (state: State, key: SetKeys, users: APIUser[]): State => { const newIds = toIds(users); - return state.update(key, ImmutableOrderedSet(), ids => ids.union(newIds)); + return state.update(key, (ids: ImmutableOrderedSet) => ids.union(newIds)); }; -const replaceSet = (state, key, users) => { +const replaceSet = (state: State, key: SetKeys, users: APIUser[]): State => { const newIds = toIds(users); return state.set(key, ImmutableOrderedSet(newIds)); }; -const maybeImportUnapproved = (state, users, filters) => { +const maybeImportUnapproved = (state: State, users: APIUser[], filters: Filter[]): State => { if (filtersMatch(FILTER_UNAPPROVED, filters)) { return mergeSet(state, 'awaitingApproval', users); } else { @@ -55,7 +76,7 @@ const maybeImportUnapproved = (state, users, filters) => { } }; -const maybeImportLatest = (state, users, filters, page) => { +const maybeImportLatest = (state: State, users: APIUser[], filters: Filter[], page: number): State => { if (page === 1 && filtersMatch(FILTER_LATEST, filters)) { return replaceSet(state, 'latestUsers', users); } else { @@ -63,14 +84,14 @@ const maybeImportLatest = (state, users, filters, page) => { } }; -const importUser = (state, user) => ( +const importUser = (state: State, user: APIUser): State => ( state.setIn(['users', user.id], ImmutableMap({ email: user.email, registration_reason: user.registration_reason, })) ); -function importUsers(state, users, filters, page) { +function importUsers(state: State, users: APIUser[], filters: Filter[], page: number): State { return state.withMutations(state => { maybeImportUnapproved(state, users, filters); maybeImportLatest(state, users, filters, page); @@ -81,7 +102,7 @@ function importUsers(state, users, filters, page) { }); } -function deleteUsers(state, accountIds) { +function deleteUsers(state: State, accountIds: string[]): State { return state.withMutations(state => { accountIds.forEach(id => { state.update('awaitingApproval', orderedSet => orderedSet.delete(id)); @@ -90,7 +111,7 @@ function deleteUsers(state, accountIds) { }); } -function approveUsers(state, users) { +function approveUsers(state: State, users: APIUser[]): State { return state.withMutations(state => { users.forEach(user => { state.update('awaitingApproval', orderedSet => orderedSet.delete(user.nickname)); @@ -99,7 +120,7 @@ function approveUsers(state, users) { }); } -function importReports(state, reports) { +function importReports(state: State, reports: APIReport[]): State { return state.withMutations(state => { reports.forEach(report => { report.statuses = report.statuses.map(status => status.id); @@ -111,7 +132,7 @@ function importReports(state, reports) { }); } -function handleReportDiffs(state, reports) { +function handleReportDiffs(state: State, reports: APIReport[]) { // Note: the reports here aren't full report objects // hence the need for a new function. return state.withMutations(state => { @@ -127,11 +148,21 @@ function handleReportDiffs(state, reports) { }); } -export default function admin(state = ReducerRecord(), action) { +const normalizeConfig = (config: any): Config => ImmutableMap(fromJS(config)); + +const normalizeConfigs = (configs: any): ImmutableList => { + return ImmutableList(fromJS(configs)).map(normalizeConfig); +}; + +const importConfigs = (state: State, configs: any): State => { + return state.set('configs', normalizeConfigs(configs)); +}; + +export default function admin(state: State = ReducerRecord(), action: AnyAction): State { switch(action.type) { case ADMIN_CONFIG_FETCH_SUCCESS: case ADMIN_CONFIG_UPDATE_SUCCESS: - return state.set('configs', fromJS(action.configs)); + return importConfigs(state, action.configs); case ADMIN_REPORTS_FETCH_SUCCESS: return importReports(state, action.reports); case ADMIN_REPORTS_PATCH_REQUEST: diff --git a/app/soapbox/reducers/alerts.js b/app/soapbox/reducers/alerts.ts similarity index 61% rename from app/soapbox/reducers/alerts.js rename to app/soapbox/reducers/alerts.ts index 31428d402..f8219d199 100644 --- a/app/soapbox/reducers/alerts.js +++ b/app/soapbox/reducers/alerts.ts @@ -15,24 +15,31 @@ const AlertRecord = ImmutableRecord({ actionLink: '', }); -const initialState = ImmutableList(); +import type { AnyAction } from 'redux'; + +type PlainAlert = Record; +type Alert = ReturnType; +type State = ImmutableList; // Get next key based on last alert -const getNextKey = state => state.size > 0 ? state.last().get('key') + 1 : 0; +const getNextKey = (state: State): number => { + const last = state.last(); + return last ? last.key + 1 : 0; +}; // Import the alert -const importAlert = (state, alert) => { +const importAlert = (state: State, alert: PlainAlert): State => { const key = getNextKey(state); const record = AlertRecord({ ...alert, key }); return state.push(record); }; // Delete an alert by its key -const deleteAlert = (state, alert) => { +const deleteAlert = (state: State, alert: PlainAlert): State => { return state.filterNot(item => item.key === alert.key); }; -export default function alerts(state = initialState, action) { +export default function alerts(state: State = ImmutableList(), action: AnyAction): State { switch(action.type) { case ALERT_SHOW: return importAlert(state, action); diff --git a/app/soapbox/reducers/filters.js b/app/soapbox/reducers/filters.js deleted file mode 100644 index 488706573..000000000 --- a/app/soapbox/reducers/filters.js +++ /dev/null @@ -1,12 +0,0 @@ -import { List as ImmutableList, fromJS } from 'immutable'; - -import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; - -export default function filters(state = ImmutableList(), action) { - switch(action.type) { - case FILTERS_FETCH_SUCCESS: - return fromJS(action.filters); - default: - return state; - } -} diff --git a/app/soapbox/reducers/filters.tsx b/app/soapbox/reducers/filters.tsx new file mode 100644 index 000000000..484b2b80a --- /dev/null +++ b/app/soapbox/reducers/filters.tsx @@ -0,0 +1,25 @@ +import { + Map as ImmutableMap, + List as ImmutableList, + fromJS, +} from 'immutable'; + +import { FILTERS_FETCH_SUCCESS } from '../actions/filters'; + +import type { AnyAction } from 'redux'; + +type Filter = ImmutableMap; +type State = ImmutableList; + +const importFilters = (_state: State, filters: unknown): State => { + return ImmutableList(fromJS(filters)).map(filter => ImmutableMap(fromJS(filter))); +}; + +export default function filters(state: State = ImmutableList(), action: AnyAction): State { + switch(action.type) { + case FILTERS_FETCH_SUCCESS: + return importFilters(state, action.filters); + default: + return state; + } +} diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 37d78e2c7..cd3316a16 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -39,15 +39,22 @@ type StatusRecord = ReturnType; type APIEntity = Record; type APIEntities = Array; -type State = ImmutableMap; +type State = ImmutableMap; -const minifyStatus = (status: StatusRecord): StatusRecord => { +export interface ReducerStatus extends StatusRecord { + account: string | null, + reblog: string | null, + poll: string | null, + quote: string | null, +} + +const minifyStatus = (status: StatusRecord): ReducerStatus => { return status.mergeWith((o, n) => n || o, { account: normalizeId(status.getIn(['account', 'id'])), reblog: normalizeId(status.getIn(['reblog', 'id'])), poll: normalizeId(status.getIn(['poll', 'id'])), quote: normalizeId(status.getIn(['quote', 'id'])), - }); + }) as ReducerStatus; }; // Gets titles of poll options from status @@ -121,14 +128,14 @@ const fixQuote = (status: StatusRecord, oldStatus?: StatusRecord): StatusRecord } }; -const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): StatusRecord => { +const fixStatus = (state: State, status: APIEntity, expandSpoilers: boolean): ReducerStatus => { const oldStatus = state.get(status.id); return normalizeStatus(status).withMutations(status => { fixQuote(status, oldStatus); calculateStatus(status, oldStatus, expandSpoilers); minifyStatus(status); - }); + }) as ReducerStatus; }; const importStatus = (state: State, status: APIEntity, expandSpoilers: boolean): State => @@ -204,13 +211,13 @@ export default function statuses(state = initialState, action: AnyAction): State return state .updateIn( [action.status.get('id'), 'pleroma', 'emoji_reactions'], - emojiReacts => simulateEmojiReact(emojiReacts, action.emoji), + emojiReacts => simulateEmojiReact(emojiReacts as any, action.emoji), ); case UNEMOJI_REACT_REQUEST: return state .updateIn( [action.status.get('id'), 'pleroma', 'emoji_reactions'], - emojiReacts => simulateUnEmojiReact(emojiReacts, action.emoji), + emojiReacts => simulateUnEmojiReact(emojiReacts as any, action.emoji), ); case FAVOURITE_FAIL: return state.get(action.status.get('id')) === undefined ? state : state.setIn([action.status.get('id'), 'favourited'], false); diff --git a/app/soapbox/selectors/index.js b/app/soapbox/selectors/index.js deleted file mode 100644 index 2db849b3e..000000000 --- a/app/soapbox/selectors/index.js +++ /dev/null @@ -1,332 +0,0 @@ -import { - Map as ImmutableMap, - List as ImmutableList, - OrderedSet as ImmutableOrderedSet, -} from 'immutable'; -import { createSelector } from 'reselect'; - -import { getSettings } from 'soapbox/actions/settings'; -import { getDomain } from 'soapbox/utils/accounts'; -import { validId } from 'soapbox/utils/auth'; -import ConfigDB from 'soapbox/utils/config_db'; -import { shouldFilter } from 'soapbox/utils/timelines'; - -const getAccountBase = (state, id) => state.getIn(['accounts', id], null); -const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null); -const getAccountRelationship = (state, id) => state.getIn(['relationships', id], null); -const getAccountMoved = (state, id) => state.getIn(['accounts', state.getIn(['accounts', id, 'moved'])]); -const getAccountMeta = (state, id) => state.getIn(['accounts_meta', id], ImmutableMap()); -const getAccountAdminData = (state, id) => state.getIn(['admin', 'users', id]); -const getAccountPatron = (state, id) => { - const url = state.getIn(['accounts', id, 'url']); - return state.getIn(['patron', 'accounts', url]); -}; - -export const makeGetAccount = () => { - return createSelector([ - getAccountBase, - getAccountCounters, - getAccountRelationship, - getAccountMoved, - getAccountMeta, - getAccountAdminData, - getAccountPatron, - ], (base, counters, relationship, moved, meta, admin, patron) => { - if (base === null) { - return null; - } - - return base.withMutations(map => { - map.merge(counters); - map.merge(meta); - map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma - map.set('relationship', relationship); - map.set('moved', moved); - map.set('patron', patron); - map.setIn(['pleroma', 'admin'], admin); - }); - }); -}; - -const findAccountsByUsername = (state, username) => { - const accounts = state.get('accounts'); - - return accounts.filter(account => { - return username.toLowerCase() === account.getIn(['acct'], '').toLowerCase(); - }); -}; - -export const findAccountByUsername = (state, username) => { - const accounts = findAccountsByUsername(state, username); - - if (accounts.size > 1) { - const me = state.get('me'); - const meURL = state.getIn(['accounts', me, 'url']); - - return accounts.find(account => { - try { - // If more than one account has the same username, try matching its host - const { host } = new URL(account.get('url')); - const { host: meHost } = new URL(meURL); - return host === meHost; - } catch { - return false; - } - }); - } else { - return accounts.first(); - } -}; - -const toServerSideType = columnType => { - switch (columnType) { - case 'home': - case 'notifications': - case 'public': - case 'thread': - return columnType; - default: - if (columnType.indexOf('list:') > -1) { - return 'home'; - } else { - return 'public'; // community, account, hashtag - } - } -}; - -export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))); - -const escapeRegExp = string => - string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string - -export const regexFromFilters = filters => { - if (filters.size === 0) { - return null; - } - - return new RegExp(filters.map(filter => { - let expr = escapeRegExp(filter.get('phrase')); - - if (filter.get('whole_word')) { - if (/^[\w]/.test(expr)) { - expr = `\\b${expr}`; - } - - if (/[\w]$/.test(expr)) { - expr = `${expr}\\b`; - } - } - - return expr; - }).join('|'), 'i'); -}; - -export const makeGetStatus = () => { - return createSelector( - [ - (state, { id }) => state.getIn(['statuses', id]), - (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]), - (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]), - (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]), - (state, { username }) => username, - getFilters, - (state) => state.get('me'), - ], - - (statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => { - if (!statusBase) { - return null; - } - - const accountUsername = accountBase.get('acct'); - //Must be owner of status if username exists - if (accountUsername !== username && username !== undefined) { - return null; - } - - if (statusReblog) { - statusReblog = statusReblog.set('account', accountReblog); - } else { - statusReblog = null; - } - - const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters); - const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index')); - - return statusBase.withMutations(map => { - map.set('reblog', statusReblog); - map.set('account', accountBase); - map.set('filtered', filtered); - }); - }, - ); -}; - -const getAlertsBase = state => state.get('alerts'); - -export const getAlerts = createSelector([getAlertsBase], (base) => { - const arr = []; - - base.forEach(item => { - arr.push({ - message: item.get('message'), - title: item.get('title'), - actionLabel: item.get('actionLabel'), - actionLink: item.get('actionLink'), - key: item.get('key'), - className: `notification-bar-${item.get('severity', 'info')}`, - activeClassName: 'snackbar--active', - dismissAfter: 6000, - style: false, - }); - }); - - return arr; -}); - -export const makeGetNotification = () => { - return createSelector([ - (state, notification) => notification, - (state, notification) => state.getIn(['accounts', notification.get('account')]), - (state, notification) => state.getIn(['accounts', notification.get('target')]), - (state, notification) => state.getIn(['statuses', notification.get('status')]), - ], (notification, account, target, status) => { - return notification.merge({ account, target, status }); - }); -}; - -export const getAccountGallery = createSelector([ - (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()), - state => state.get('statuses'), - state => state.get('accounts'), -], (statusIds, statuses, accounts) => { - - return statusIds.reduce((medias, statusId) => { - const status = statuses.get(statusId); - const account = accounts.get(status.get('account')); - if (status.get('reblog')) return medias; - return medias.concat(status.get('media_attachments') - .map(media => media.merge({ status, account }))); - }, ImmutableList()); -}); - -export const makeGetChat = () => { - return createSelector( - [ - (state, { id }) => state.getIn(['chats', 'items', id]), - (state, { id }) => state.getIn(['accounts', state.getIn(['chats', 'items', id, 'account'])]), - (state, { last_message }) => state.getIn(['chat_messages', last_message]), - ], - - (chat, account, lastMessage) => { - if (!chat) return null; - - return chat.withMutations(map => { - map.set('account', account); - map.set('last_message', lastMessage); - }); - }, - ); -}; - -export const makeGetReport = () => { - const getStatus = makeGetStatus(); - - return createSelector( - [ - (state, id) => state.getIn(['admin', 'reports', id]), - (state, id) => state.getIn(['admin', 'reports', id, 'statuses']).map( - statusId => state.getIn(['statuses', statusId])) - .filter(s => s) - .map(s => getStatus(state, s.toJS())), - ], - - (report, statuses) => { - if (!report) return null; - return report.set('statuses', statuses); - }, - ); -}; - -const getAuthUserIds = createSelector([ - state => state.getIn(['auth', 'users'], ImmutableMap()), -], authUsers => { - return authUsers.reduce((ids, authUser) => { - try { - const id = authUser.get('id'); - return validId(id) ? ids.add(id) : ids; - } catch { - return ids; - } - }, ImmutableOrderedSet()); -}); - -export const makeGetOtherAccounts = () => { - return createSelector([ - state => state.get('accounts'), - getAuthUserIds, - state => state.get('me'), - ], - (accounts, authUserIds, me) => { - return authUserIds - .reduce((list, id) => { - if (id === me) return list; - const account = accounts.get(id); - return account ? list.push(account) : list; - }, ImmutableList()); - }); -}; - -const getSimplePolicy = createSelector([ - state => state.getIn(['admin', 'configs'], ImmutableMap()), - state => state.getIn(['instance', 'pleroma', 'metadata', 'federation', 'mrf_simple'], ImmutableMap()), -], (configs, instancePolicy) => { - return instancePolicy.merge(ConfigDB.toSimplePolicy(configs)); -}); - -const getRemoteInstanceFavicon = (state, host) => ( - state.get('accounts') - .find(account => getDomain(account) === host, null, ImmutableMap()) - .getIn(['pleroma', 'favicon']) -); - -const getRemoteInstanceFederation = (state, host) => ( - getSimplePolicy(state) - .map(hosts => hosts.includes(host)) -); - -export const makeGetHosts = () => { - return createSelector([getSimplePolicy], (simplePolicy) => { - return simplePolicy - .deleteAll(['accept', 'reject_deletes', 'report_removal']) - .reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet()) - .sort(); - }); -}; - -export const makeGetRemoteInstance = () => { - return createSelector([ - (state, host) => host, - getRemoteInstanceFavicon, - getRemoteInstanceFederation, - ], (host, favicon, federation) => { - return ImmutableMap({ - host, - favicon, - federation, - }); - }); -}; - -export const makeGetStatusIds = () => createSelector([ - (state, { type, prefix }) => getSettings(state).get(prefix || type, ImmutableMap()), - (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableOrderedSet()), - (state) => state.get('statuses'), - (state) => state.get('me'), -], (columnSettings, statusIds, statuses, me) => { - return statusIds.filter(id => { - const status = statuses.get(id); - if (!status) return true; - return !shouldFilter(status, columnSettings); - }); -}); diff --git a/app/soapbox/selectors/index.ts b/app/soapbox/selectors/index.ts new file mode 100644 index 000000000..f0ba336b3 --- /dev/null +++ b/app/soapbox/selectors/index.ts @@ -0,0 +1,360 @@ +import { + Map as ImmutableMap, + List as ImmutableList, + OrderedSet as ImmutableOrderedSet, + fromJS, +} from 'immutable'; +import { createSelector } from 'reselect'; + +import { getSettings } from 'soapbox/actions/settings'; +import { getDomain } from 'soapbox/utils/accounts'; +import { validId } from 'soapbox/utils/auth'; +import ConfigDB from 'soapbox/utils/config_db'; +import { shouldFilter } from 'soapbox/utils/timelines'; + +import type { RootState } from 'soapbox/store'; +import type { Notification } from 'soapbox/types/entities'; + +const normalizeId = (id: any): string => typeof id === 'string' ? id : ''; + +const getAccountBase = (state: RootState, id: string) => state.accounts.get(id); +const getAccountCounters = (state: RootState, id: string) => state.accounts_counters.get(id); +const getAccountRelationship = (state: RootState, id: string) => state.relationships.get(id); +const getAccountMoved = (state: RootState, id: string) => state.accounts.get(state.accounts.get(id)?.moved || ''); +const getAccountMeta = (state: RootState, id: string) => state.accounts_meta.get(id, ImmutableMap()); +const getAccountAdminData = (state: RootState, id: string) => state.admin.users.get(id); +const getAccountPatron = (state: RootState, id: string) => { + const url = state.accounts.get(id)?.url; + return state.patron.getIn(['accounts', url]); +}; + +export const makeGetAccount = () => { + return createSelector([ + getAccountBase, + getAccountCounters, + getAccountRelationship, + getAccountMoved, + getAccountMeta, + getAccountAdminData, + getAccountPatron, + ], (base, counters, relationship, moved, meta, admin, patron) => { + if (!base) return null; + + return base.withMutations(map => { + map.merge(counters); + map.merge(meta); + map.set('pleroma', meta.get('pleroma', ImmutableMap()).merge(base.get('pleroma', ImmutableMap()))); // Lol, thanks Pleroma + map.set('relationship', relationship); + map.set('moved', moved || null); + map.set('patron', patron); + map.setIn(['pleroma', 'admin'], admin); + }); + }); +}; + +const findAccountsByUsername = (state: RootState, username: string) => { + const accounts = state.accounts; + + return accounts.filter(account => { + return username.toLowerCase() === account.acct.toLowerCase(); + }); +}; + +export const findAccountByUsername = (state: RootState, username: string) => { + const accounts = findAccountsByUsername(state, username); + + if (accounts.size > 1) { + const me = state.me; + const meURL = state.accounts.get(me)?.url || ''; + + return accounts.find(account => { + try { + // If more than one account has the same username, try matching its host + const { host } = new URL(account.url); + const { host: meHost } = new URL(meURL); + return host === meHost; + } catch { + return false; + } + }); + } else { + return accounts.first(); + } +}; + +const toServerSideType = (columnType: string): string => { + switch (columnType) { + case 'home': + case 'notifications': + case 'public': + case 'thread': + return columnType; + default: + if (columnType.indexOf('list:') > -1) { + return 'home'; + } else { + return 'public'; // community, account, hashtag + } + } +}; + +type FilterContext = { contextType: string }; + +export const getFilters = (state: RootState, { contextType }: FilterContext) => { + return state.filters.filter((filter): boolean => { + return contextType + && filter.get('context').includes(toServerSideType(contextType)) + && (filter.get('expires_at') === null + || Date.parse(filter.get('expires_at')) > new Date().getTime()); + }); +}; + +const escapeRegExp = (string: string) => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +export const regexFromFilters = (filters: ImmutableList>) => { + if (filters.size === 0) return null; + + return new RegExp(filters.map(filter => { + let expr = escapeRegExp(filter.get('phrase')); + + if (filter.get('whole_word')) { + if (/^[\w]/.test(expr)) { + expr = `\\b${expr}`; + } + + if (/[\w]$/.test(expr)) { + expr = `${expr}\\b`; + } + } + + return expr; + }).join('|'), 'i'); +}; + +type APIStatus = { id: string, username: string }; + +export const makeGetStatus = () => { + return createSelector( + [ + (state: RootState, { id }: APIStatus) => state.statuses.get(id), + (state: RootState, { id }: APIStatus) => state.statuses.get(state.statuses.get(id)?.reblog || ''), + (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(id)?.account || ''), + (state: RootState, { id }: APIStatus) => state.accounts.get(state.statuses.get(state.statuses.get(id)?.reblog || '')?.account || ''), + (_state: RootState, { username }: APIStatus) => username, + getFilters, + (state: RootState) => state.me, + ], + + (statusBase, statusReblog, accountBase, accountReblog, username, filters, me) => { + if (!statusBase || !accountBase) return null; + + const accountUsername = accountBase.acct; + //Must be owner of status if username exists + if (accountUsername !== username && username !== undefined) { + return null; + } + + if (statusReblog && accountReblog) { + // @ts-ignore AAHHHHH + statusReblog = statusReblog.set('account', accountReblog); + } else { + statusReblog = undefined; + } + + const regex = (accountReblog || accountBase).id !== me && regexFromFilters(filters); + const filtered = regex && regex.test(statusReblog?.search_index || statusBase.search_index); + + return statusBase.withMutations(map => { + map.set('reblog', statusReblog || null); + // @ts-ignore :( + map.set('account', accountBase || null); + map.set('filtered', Boolean(filtered)); + }); + }, + ); +}; + +const getAlertsBase = (state: RootState) => state.alerts; + +const buildAlert = (item: any) => { + return { + message: item.message, + title: item.title, + actionLabel: item.actionLabel, + actionLink: item.actionLink, + key: item.key, + className: `notification-bar-${item.severity}`, + activeClassName: 'snackbar--active', + dismissAfter: 6000, + style: false, + }; +}; + +type Alert = ReturnType; + +export const getAlerts = createSelector([getAlertsBase], (base): Alert[] => { + const arr: Alert[] = []; + base.forEach(item => arr.push(buildAlert(item))); + return arr; +}); + +export const makeGetNotification = () => { + return createSelector([ + (_state: RootState, notification: Notification) => notification, + (state: RootState, notification: Notification) => state.accounts.get(normalizeId(notification.account)), + (state: RootState, notification: Notification) => state.accounts.get(normalizeId(notification.target)), + (state: RootState, notification: Notification) => state.statuses.get(normalizeId(notification.status)), + ], (notification, account, target, status) => { + return notification.merge({ + // @ts-ignore + account: account || null, + // @ts-ignore + target: target || null, + // @ts-ignore + status: status || null, + }); + }); +}; + +export const getAccountGallery = createSelector([ + (state: RootState, id: string) => state.timelines.getIn([`account:${id}:media`, 'items'], ImmutableList()), + (state: RootState) => state.statuses, + (state: RootState) => state.accounts, +], (statusIds, statuses, accounts) => { + + return statusIds.reduce((medias: ImmutableList, statusId: string) => { + const status = statuses.get(statusId); + if (!status) return medias; + if (status.reblog) return medias; + if (typeof status.account !== 'string') return medias; + + const account = accounts.get(status.account); + + return medias.concat( + status.media_attachments.map(media => media.merge({ status, account }))); + }, ImmutableList()); +}); + +type APIChat = { id: string, last_message: string }; + +export const makeGetChat = () => { + return createSelector( + [ + (state: RootState, { id }: APIChat) => state.chats.getIn(['items', id]), + (state: RootState, { id }: APIChat) => state.accounts.get(state.chats.getIn(['items', id, 'account'])), + (state: RootState, { last_message }: APIChat) => state.chat_messages.get(last_message), + ], + + (chat, account, lastMessage: string) => { + if (!chat) return null; + + return chat.withMutations((map: ImmutableMap) => { + map.set('account', account); + map.set('last_message', lastMessage); + }); + }, + ); +}; + +export const makeGetReport = () => { + const getStatus = makeGetStatus(); + + return createSelector( + [ + (state: RootState, id: string) => state.admin.reports.get(id), + (state: RootState, id: string) => ImmutableList(fromJS(state.admin.reports.getIn([id, 'statuses']))).map( + statusId => state.statuses.get(normalizeId(statusId))) + .filter((s: any) => s) + .map((s: any) => getStatus(state, s.toJS())), + ], + + (report, statuses) => { + if (!report) return null; + return report.set('statuses', statuses); + }, + ); +}; + +const getAuthUserIds = createSelector([ + (state: RootState) => state.auth.get('users', ImmutableMap()), +], authUsers => { + return authUsers.reduce((ids: ImmutableOrderedSet, authUser: ImmutableMap) => { + try { + const id = authUser.get('id'); + return validId(id) ? ids.add(id) : ids; + } catch { + return ids; + } + }, ImmutableOrderedSet()); +}); + +export const makeGetOtherAccounts = () => { + return createSelector([ + (state: RootState) => state.accounts, + getAuthUserIds, + (state: RootState) => state.me, + ], + (accounts, authUserIds, me) => { + return authUserIds + .reduce((list: ImmutableList, id: string) => { + if (id === me) return list; + const account = accounts.get(id); + return account ? list.push(account) : list; + }, ImmutableList()); + }); +}; + +const getSimplePolicy = createSelector([ + (state: RootState) => state.admin.configs, + (state: RootState) => state.instance.pleroma.getIn(['metadata', 'federation', 'mrf_simple'], ImmutableMap()) as ImmutableMap, +], (configs, instancePolicy: ImmutableMap) => { + return instancePolicy.merge(ConfigDB.toSimplePolicy(configs)); +}); + +const getRemoteInstanceFavicon = (state: RootState, host: string) => ( + (state.accounts.find(account => getDomain(account) === host, null) || ImmutableMap()) + .getIn(['pleroma', 'favicon']) +); + +const getRemoteInstanceFederation = (state: RootState, host: string) => ( + getSimplePolicy(state) + .map(hosts => hosts.includes(host)) +); + +export const makeGetHosts = () => { + return createSelector([getSimplePolicy], (simplePolicy) => { + return simplePolicy + .deleteAll(['accept', 'reject_deletes', 'report_removal']) + .reduce((acc, hosts) => acc.union(hosts), ImmutableOrderedSet()) + .sort(); + }); +}; + +export const makeGetRemoteInstance = () => { + return createSelector([ + (_state: RootState, host: string) => host, + getRemoteInstanceFavicon, + getRemoteInstanceFederation, + ], (host, favicon, federation) => { + return ImmutableMap({ + host, + favicon, + federation, + }); + }); +}; + +type ColumnQuery = { type: string, prefix?: string }; + +export const makeGetStatusIds = () => createSelector([ + (state: RootState, { type, prefix }: ColumnQuery) => getSettings(state).get(prefix || type, ImmutableMap()), + (state: RootState, { type }: ColumnQuery) => state.timelines.getIn([type, 'items'], ImmutableOrderedSet()), + (state: RootState) => state.statuses, +], (columnSettings, statusIds: string[], statuses) => { + return statusIds.filter((id: string) => { + const status = statuses.get(id); + if (!status) return true; + return !shouldFilter(status, columnSettings); + }); +}); diff --git a/app/soapbox/types/entities.ts b/app/soapbox/types/entities.ts index cecdf235d..942e5f4f8 100644 --- a/app/soapbox/types/entities.ts +++ b/app/soapbox/types/entities.ts @@ -14,7 +14,6 @@ import { import type { Record as ImmutableRecord } from 'immutable'; -type Account = ReturnType; type Attachment = ReturnType; type Card = ReturnType; type Emoji = ReturnType; @@ -24,7 +23,18 @@ type Mention = ReturnType; type Notification = ReturnType; type Poll = ReturnType; type PollOption = ReturnType; -type Status = ReturnType; + +interface Account extends ReturnType { + // HACK: we can't do a circular reference in the Record definition itself, + // so do it here. + moved: EmbeddedEntity; +} + +interface Status extends ReturnType { + // HACK: same as above + quote: EmbeddedEntity; + reblog: EmbeddedEntity; +} // Utility types type APIEntity = Record; diff --git a/app/soapbox/types/soapbox.ts b/app/soapbox/types/soapbox.ts index 386734d3b..32c2f681c 100644 --- a/app/soapbox/types/soapbox.ts +++ b/app/soapbox/types/soapbox.ts @@ -5,12 +5,15 @@ import { SoapboxConfigRecord, } from 'soapbox/normalizers/soapbox/soapbox_config'; +type Me = string | null | false | undefined; + type PromoPanelItem = ReturnType; type FooterItem = ReturnType; type CryptoAddress = ReturnType; type SoapboxConfig = ReturnType; export { + Me, PromoPanelItem, FooterItem, CryptoAddress, diff --git a/app/soapbox/utils/__tests__/accounts-test.js b/app/soapbox/utils/__tests__/accounts-test.js index 15a42ec57..cafedce35 100644 --- a/app/soapbox/utils/__tests__/accounts-test.js +++ b/app/soapbox/utils/__tests__/accounts-test.js @@ -1,15 +1,11 @@ -import { fromJS } from 'immutable'; +import { AccountRecord } from 'soapbox/normalizers'; import { getDomain, - acctFull, - isStaff, - isAdmin, - isModerator, } from '../accounts'; describe('getDomain', () => { - const account = fromJS({ + const account = AccountRecord({ acct: 'alice', url: 'https://party.com/users/alice', }); @@ -17,101 +13,3 @@ describe('getDomain', () => { expect(getDomain(account)).toEqual('party.com'); }); }); - -describe('acctFull', () => { - describe('with a local user', () => { - const account = fromJS({ - acct: 'alice', - url: 'https://party.com/users/alice', - }); - it('returns the full acct', () => { - expect(acctFull(account)).toEqual('alice@party.com'); - }); - }); - - describe('with a remote user', () => { - const account = fromJS({ - acct: 'bob@pool.com', - url: 'https://pool.com/users/bob', - }); - it('returns the full acct', () => { - expect(acctFull(account)).toEqual('bob@pool.com'); - }); - }); -}); - -describe('isStaff', () => { - describe('with empty user', () => { - const account = fromJS({}); - it('returns false', () => { - expect(isStaff(account)).toBe(false); - }); - }); - - describe('with Pleroma admin', () => { - const admin = fromJS({ pleroma: { is_admin: true } }); - it('returns true', () => { - expect(isStaff(admin)).toBe(true); - }); - }); - - describe('with Pleroma moderator', () => { - const mod = fromJS({ pleroma: { is_moderator: true } }); - it('returns true', () => { - expect(isStaff(mod)).toBe(true); - }); - }); - - describe('with undefined', () => { - const account = undefined; - it('returns false', () => { - expect(isStaff(account)).toBe(false); - }); - }); -}); - -describe('isAdmin', () => { - describe('with empty user', () => { - const account = fromJS({}); - it('returns false', () => { - expect(isAdmin(account)).toBe(false); - }); - }); - - describe('with Pleroma admin', () => { - const admin = fromJS({ pleroma: { is_admin: true } }); - it('returns true', () => { - expect(isAdmin(admin)).toBe(true); - }); - }); - - describe('with Pleroma moderator', () => { - const mod = fromJS({ pleroma: { is_moderator: true } }); - it('returns false', () => { - expect(isAdmin(mod)).toBe(false); - }); - }); -}); - -describe('isModerator', () => { - describe('with empty user', () => { - const account = fromJS({}); - it('returns false', () => { - expect(isModerator(account)).toBe(false); - }); - }); - - describe('with Pleroma admin', () => { - const admin = fromJS({ pleroma: { is_admin: true } }); - it('returns false', () => { - expect(isModerator(admin)).toBe(false); - }); - }); - - describe('with Pleroma moderator', () => { - const mod = fromJS({ pleroma: { is_moderator: true } }); - it('returns true', () => { - expect(isModerator(mod)).toBe(true); - }); - }); -}); diff --git a/app/soapbox/utils/__tests__/emoji_reacts-test.js b/app/soapbox/utils/__tests__/emoji_reacts-test.js index c4b05960c..d67744fef 100644 --- a/app/soapbox/utils/__tests__/emoji_reacts-test.js +++ b/app/soapbox/utils/__tests__/emoji_reacts-test.js @@ -11,14 +11,14 @@ import { simulateUnEmojiReact, } from '../emoji_reacts'; -const ALLOWED_EMOJI = [ +const ALLOWED_EMOJI = fromJS([ '👍', '❤', '😂', '😯', '😢', '😡', -]; +]); describe('filterEmoji', () => { describe('with a mix of allowed and disallowed emoji', () => { @@ -168,7 +168,7 @@ describe('getReactForStatus', () => { }); it('returns undefined when a status has no reacts (or favourites)', () => { - const status = fromJS([]); + const status = fromJS({}); expect(getReactForStatus(status)).toEqual(undefined); }); diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index 34e4429f9..2c942eeaa 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -1,27 +1,21 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { Account } from 'soapbox/types/entities'; +import type { Account } from 'soapbox/types/entities'; -const getDomainFromURL = (account: ImmutableMap): string => { +const getDomainFromURL = (account: Account): string => { try { - const url = account.get('url'); + const url = account.url; return new URL(url).host; } catch { return ''; } }; -export const getDomain = (account: ImmutableMap): string => { - const domain = account.get('acct', '').split('@')[1]; +export const getDomain = (account: Account): string => { + const domain = account.acct.split('@')[1]; return domain ? domain : getDomainFromURL(account); }; -export const guessFqn = (account: ImmutableMap): string => { - const [user, domain] = account.get('acct', '').split('@'); - if (!domain) return [user, getDomainFromURL(account)].join('@'); - return account.get('acct', ''); -}; - export const getBaseURL = (account: ImmutableMap): string => { try { const url = account.get('url'); @@ -31,27 +25,10 @@ export const getBaseURL = (account: ImmutableMap): string => { } }; -// user@domain even for local users -export const acctFull = (account: ImmutableMap): string => ( - account.get('fqn') || guessFqn(account) || '' -); - export const getAcct = (account: Account, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct ); -export const isStaff = (account: ImmutableMap = ImmutableMap()): boolean => ( - [isAdmin, isModerator].some(f => f(account) === true) -); - -export const isAdmin = (account: ImmutableMap): boolean => ( - account.getIn(['pleroma', 'is_admin']) === true -); - -export const isModerator = (account: ImmutableMap): boolean => ( - account.getIn(['pleroma', 'is_moderator']) === true -); - export const getFollowDifference = (state: ImmutableMap, accountId: string, type: string): number => { const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet()); const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0)); diff --git a/app/soapbox/utils/config_db.ts b/app/soapbox/utils/config_db.ts index deb5d048a..48f3118b5 100644 --- a/app/soapbox/utils/config_db.ts +++ b/app/soapbox/utils/config_db.ts @@ -6,8 +6,8 @@ import { } from 'immutable'; import { trimStart } from 'lodash'; -type Config = ImmutableMap; -type Policy = ImmutableMap; +export type Config = ImmutableMap; +export type Policy = ImmutableMap; const find = ( configs: ImmutableList, diff --git a/app/soapbox/utils/emoji_reacts.js b/app/soapbox/utils/emoji_reacts.ts similarity index 57% rename from app/soapbox/utils/emoji_reacts.js rename to app/soapbox/utils/emoji_reacts.ts index 788c9c867..9662c8458 100644 --- a/app/soapbox/utils/emoji_reacts.js +++ b/app/soapbox/utils/emoji_reacts.ts @@ -3,31 +3,36 @@ import { List as ImmutableList, } from 'immutable'; +import type { Me } from 'soapbox/types/soapbox'; + // https://emojipedia.org/facebook // I've customized them. -export const ALLOWED_EMOJI = [ +export const ALLOWED_EMOJI = ImmutableList([ '👍', '❤️', '😆', '😮', '😢', '😩', -]; +]); + +type Account = ImmutableMap; +type EmojiReact = ImmutableMap; -export const sortEmoji = emojiReacts => ( +export const sortEmoji = (emojiReacts: ImmutableList): ImmutableList => ( emojiReacts.sortBy(emojiReact => -emojiReact.get('count')) ); -export const mergeEmoji = emojiReacts => ( +export const mergeEmoji = (emojiReacts: ImmutableList): ImmutableList => ( emojiReacts // TODO: Merge similar emoji ); -export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), favouritesCount, favourited) => { +export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), favouritesCount: number, favourited: boolean) => { if (!favouritesCount) return emojiReacts; const likeIndex = emojiReacts.findIndex(emojiReact => emojiReact.get('name') === '👍'); if (likeIndex > -1) { - const likeCount = emojiReacts.getIn([likeIndex, 'count']); - favourited = favourited || emojiReacts.getIn([likeIndex, 'me'], false); + const likeCount = Number(emojiReacts.getIn([likeIndex, 'count'])); + favourited = favourited || Boolean(emojiReacts.getIn([likeIndex, 'me'], false)); return emojiReacts .setIn([likeIndex, 'count'], likeCount + favouritesCount) .setIn([likeIndex, 'me'], favourited); @@ -36,24 +41,24 @@ export const mergeEmojiFavourites = (emojiReacts = ImmutableList(), favouritesCo } }; -const hasMultiReactions = (emojiReacts, account) => ( +const hasMultiReactions = (emojiReacts: ImmutableList, account: Account): boolean => ( emojiReacts.filter( e => e.get('accounts').filter( - a => a.get('id') === account.get('id'), + (a: Account) => a.get('id') === account.get('id'), ).count() > 0, ).count() > 1 ); -const inAccounts = (accounts, id) => ( +const inAccounts = (accounts: ImmutableList, id: string): boolean => ( accounts.filter(a => a.get('id') === id).count() > 0 ); -export const oneEmojiPerAccount = (emojiReacts, me) => { +export const oneEmojiPerAccount = (emojiReacts: ImmutableList, me: Me) => { emojiReacts = emojiReacts.reverse(); return emojiReacts.reduce((acc, cur, idx) => { const accounts = cur.get('accounts', ImmutableList()) - .filter(a => !hasMultiReactions(acc, a)); + .filter((a: Account) => !hasMultiReactions(acc, a)); return acc.set(idx, cur.merge({ accounts: accounts, @@ -65,30 +70,33 @@ export const oneEmojiPerAccount = (emojiReacts, me) => { .reverse(); }; -export const filterEmoji = (emojiReacts, allowedEmoji=ALLOWED_EMOJI) => ( +export const filterEmoji = (emojiReacts: ImmutableList, allowedEmoji=ALLOWED_EMOJI): ImmutableList => ( emojiReacts.filter(emojiReact => ( allowedEmoji.includes(emojiReact.get('name')) ))); -export const reduceEmoji = (emojiReacts, favouritesCount, favourited, allowedEmoji=ALLOWED_EMOJI) => ( +export const reduceEmoji = (emojiReacts: ImmutableList, favouritesCount: number, favourited: boolean, allowedEmoji=ALLOWED_EMOJI): ImmutableList => ( filterEmoji(sortEmoji(mergeEmoji(mergeEmojiFavourites( emojiReacts, favouritesCount, favourited, ))), allowedEmoji)); -export const getReactForStatus = (status, allowedEmoji=ALLOWED_EMOJI) => { - return reduceEmoji( +export const getReactForStatus = (status: any, allowedEmoji=ALLOWED_EMOJI): string | undefined => { + const result = reduceEmoji( status.getIn(['pleroma', 'emoji_reactions'], ImmutableList()), status.get('favourites_count', 0), status.get('favourited'), allowedEmoji, ).filter(e => e.get('me') === true) .getIn([0, 'name']); + + return typeof result === 'string' ? result : undefined; }; -export const simulateEmojiReact = (emojiReacts, emoji) => { +export const simulateEmojiReact = (emojiReacts: ImmutableList, emoji: string) => { const idx = emojiReacts.findIndex(e => e.get('name') === emoji); - if (idx > -1) { - const emojiReact = emojiReacts.get(idx); + const emojiReact = emojiReacts.get(idx); + + if (idx > -1 && emojiReact) { return emojiReacts.set(idx, emojiReact.merge({ count: emojiReact.get('count') + 1, me: true, @@ -102,12 +110,13 @@ export const simulateEmojiReact = (emojiReacts, emoji) => { } }; -export const simulateUnEmojiReact = (emojiReacts, emoji) => { +export const simulateUnEmojiReact = (emojiReacts: ImmutableList, emoji: string) => { const idx = emojiReacts.findIndex(e => e.get('name') === emoji && e.get('me') === true); - if (idx > -1) { - const emojiReact = emojiReacts.get(idx); + const emojiReact = emojiReacts.get(idx); + + if (emojiReact) { const newCount = emojiReact.get('count') - 1; if (newCount < 1) return emojiReacts.delete(idx); return emojiReacts.set(idx, emojiReact.merge({ diff --git a/app/styles/components/emoji-reacts.scss b/app/styles/components/emoji-reacts.scss index dd2981fca..082175985 100644 --- a/app/styles/components/emoji-reacts.scss +++ b/app/styles/components/emoji-reacts.scss @@ -121,8 +121,8 @@ background: transparent; img { - width: 30px; - height: 30px; + width: 36px; + height: 36px; padding: 3px; transition: 0.1s; }