From b1d2681115bb5f01f47d1de225da30eac9ff1240 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Mar 2022 15:17:39 -0500 Subject: [PATCH 01/29] WIP emoji reacts --- app/soapbox/components/emoji_selector.js | 2 +- app/soapbox/components/status_action_bar.js | 14 ++++++++------ app/styles/components/emoji-reacts.scss | 4 ++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/soapbox/components/emoji_selector.js b/app/soapbox/components/emoji_selector.js index 73db189a3..f41f23f41 100644 --- a/app/soapbox/components/emoji_selector.js +++ b/app/soapbox/components/emoji_selector.js @@ -102,7 +102,7 @@ class EmojiSelector extends ImmutablePureComponent { className='emoji-react-selector-container' >
diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index b3bc4786c..5844ac41f 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -9,6 +9,7 @@ import { connect } from 'react-redux'; import { Link, withRouter } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; +import EmojiSelector from 'soapbox/components/emoji_selector'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { isUserTouching } from 'soapbox/is_mobile'; import { isStaff, isAdmin } from 'soapbox/utils/accounts'; @@ -541,7 +542,8 @@ class StatusActionBar extends ImmutablePureComponent { } render() { - const { status, intl, allowedEmoji, features, me } = this.props; + const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props; + const { emojiSelectorVisible } = this.state; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -660,16 +662,16 @@ class StatusActionBar extends ImmutablePureComponent {
- {/* */} + /> Date: Thu, 31 Mar 2022 14:13:43 -0500 Subject: [PATCH 02/29] Convert components/status to Typescript --- app/soapbox/components/scrollable_list.js | 4 +- .../components/{status.js => status.tsx} | 398 ++++++++++-------- app/soapbox/normalizers/account.ts | 2 +- app/soapbox/normalizers/status.ts | 2 + app/soapbox/types/entities.ts | 14 +- 5 files changed, 236 insertions(+), 184 deletions(-) rename app/soapbox/components/{status.js => status.tsx} (54%) 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/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..2c5a2097d 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.tsx @@ -1,10 +1,8 @@ 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 { injectIntl, FormattedMessage, IntlShape } from 'react-intl'; import { NavLink, withRouter } from 'react-router-dom'; import Icon from 'soapbox/components/icon'; @@ -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.reblog && typeof status.reblog === 'object') { + status = status.reblog; } - if (status.get('reblog', null) !== null && typeof status.get('reblog') === 'object') { - status = status.get('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 { +interface IStatus { + 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, +} - 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 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; + const html = String(status.getIn(['card', 'html'])); - if (video.external_video_id && status.card?.html) { + if (video.external_video_id && html) { 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 = (
); } 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 +568,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 +595,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 +649,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 +659,7 @@ class Status extends ImmutablePureComponent { {quote} | null, + moved: null as EmbeddedEntity, note: '', pleroma: ImmutableMap(), source: ImmutableMap(), diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index c69354c79..4d1260ffe 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -31,6 +31,7 @@ export const StatusRecord = ImmutableRecord({ 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 +56,7 @@ export const StatusRecord = ImmutableRecord({ // Internal fields contentHtml: '', + expectsCard: false, filtered: false, hidden: false, search_index: '', 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; From c077a4ea5881caab40baa6047a3ab077402b904e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 31 Mar 2022 14:27:44 -0500 Subject: [PATCH 03/29] Improve status.card normalization (it will never be a string) --- app/soapbox/components/status.tsx | 5 ++--- app/soapbox/normalizers/status.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 2c5a2097d..473d12eb3 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -465,9 +465,8 @@ class Status extends ImmutablePureComponent { ); } else if (size === 1 && firstAttachment.type === 'video') { const video = firstAttachment; - const html = String(status.getIn(['card', 'html'])); - if (video.external_video_id && html) { + if (video.external_video_id && status.card) { const { mediaWrapperWidth } = this.state; const getHeight = (): number => { @@ -484,7 +483,7 @@ class Status extends ImmutablePureComponent { ref={this.setRef} className='status-card__image status-card-video' style={height ? { height } : undefined} - dangerouslySetInnerHTML={{ __html: html }} + dangerouslySetInnerHTML={{ __html: status.card.html }} />
); diff --git a/app/soapbox/normalizers/status.ts b/app/soapbox/normalizers/status.ts index 4d1260ffe..6496111fd 100644 --- a/app/soapbox/normalizers/status.ts +++ b/app/soapbox/normalizers/status.ts @@ -25,7 +25,7 @@ export const StatusRecord = ImmutableRecord({ 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(), From 830fb67215c7a4a7c4ded6452d9a370f2678e6a7 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 31 Mar 2022 14:29:04 -0500 Subject: [PATCH 04/29] Fix Jest snapshot --- .../__tests__/__snapshots__/emoji_selector-test.js.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..154cc5603 100644 --- a/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap +++ b/app/soapbox/components/__tests__/__snapshots__/emoji_selector-test.js.snap @@ -8,7 +8,7 @@ exports[` renders correctly 1`] = ` tabIndex="-1" >
))}
diff --git a/app/soapbox/components/ui/emoji/emoji.tsx b/app/soapbox/components/ui/emoji/emoji.tsx new file mode 100644 index 000000000..d06b44d15 --- /dev/null +++ b/app/soapbox/components/ui/emoji/emoji.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +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/index.ts b/app/soapbox/components/ui/index.ts index 302738ed9..d9cd27fb0 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -2,6 +2,7 @@ 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 Form } from './form/form'; export { default as FormActions } from './form-actions/form-actions'; export { default as FormGroup } from './form-group/form-group'; From 5e8472e29d89f379ee4c47fe26b5eaaae7264ea8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 31 Mar 2022 20:47:28 -0500 Subject: [PATCH 10/29] EmojiSelector: fix onReact prop --- app/soapbox/components/emoji_selector.tsx | 8 +++---- app/soapbox/components/status_action_bar.js | 24 ++++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/app/soapbox/components/emoji_selector.tsx b/app/soapbox/components/emoji_selector.tsx index 8e8c94d49..15a0146c5 100644 --- a/app/soapbox/components/emoji_selector.tsx +++ b/app/soapbox/components/emoji_selector.tsx @@ -16,7 +16,7 @@ const mapStateToProps = (state: RootState) => ({ interface IEmojiSelector { allowedEmoji: ImmutableList, - onReact: (emoji: string) => (e?: MouseEvent) => void, + onReact: (emoji: string) => void, onUnfocus: () => void, visible: boolean, focused?: boolean, @@ -25,7 +25,7 @@ interface IEmojiSelector { class EmojiSelector extends ImmutablePureComponent { static defaultProps: Partial = { - onReact: () => () => {}, + onReact: () => {}, onUnfocus: () => {}, visible: false, } @@ -87,10 +87,10 @@ class EmojiSelector extends ImmutablePureComponent { } } - handleReact = (emoji: string) => () => { + handleReact = (emoji: string) => (): void => { const { onReact, focused, onUnfocus } = this.props; - onReact(emoji)(); + onReact(emoji); if (focused) { onUnfocus(); diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index 5844ac41f..367ae7b04 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -168,24 +168,28 @@ class StatusActionBar extends ImmutablePureComponent { 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); } } + handleReact = emoji => { + const { me, dispatch, onOpenUnauthorizedModal, status } = this.props; + if (me) { + dispatch(simpleEmojiReact(status, emoji)); + } else { + onOpenUnauthorizedModal('FAVOURITE'); + } + this.setState({ emojiSelectorVisible: false }); + } + handleReactClick = emoji => { return e => { - const { me, dispatch, onOpenUnauthorizedModal, status } = this.props; - if (me) { - dispatch(simpleEmojiReact(status, emoji)); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - this.setState({ emojiSelectorVisible: false }); + this.handleReact(emoji); }; } @@ -667,7 +671,7 @@ class StatusActionBar extends ImmutablePureComponent { onMouseLeave={this.handleLikeButtonLeave} > Date: Thu, 31 Mar 2022 21:17:29 -0500 Subject: [PATCH 11/29] Create now emoji-selector functional component (wip) --- app/soapbox/components/emoji_selector.tsx | 16 ++++-- .../ui/emoji-selector/emoji-selector.tsx | 56 +++++++++++++++++++ app/soapbox/components/ui/index.ts | 1 + 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 app/soapbox/components/ui/emoji-selector/emoji-selector.tsx diff --git a/app/soapbox/components/emoji_selector.tsx b/app/soapbox/components/emoji_selector.tsx index 15a0146c5..0533f6a69 100644 --- a/app/soapbox/components/emoji_selector.tsx +++ b/app/soapbox/components/emoji_selector.tsx @@ -1,11 +1,11 @@ -import classNames from 'classnames'; +// 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 { Emoji } from 'soapbox/components/ui'; +import { EmojiSelector as RealEmojiSelector } from 'soapbox/components/ui'; import type { List as ImmutableList } from 'immutable'; import type { RootState } from 'soapbox/store'; @@ -106,11 +106,11 @@ class EmojiSelector extends ImmutablePureComponent { } render() { - const { visible, focused, allowedEmoji } = this.props; + const { visible, focused, allowedEmoji, onReact } = this.props; return ( -
{ ))} -
+
*/} + ); } 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..15fbe595a --- /dev/null +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -0,0 +1,56 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Emoji } 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/index.ts b/app/soapbox/components/ui/index.ts index d9cd27fb0..fa60595f5 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -3,6 +3,7 @@ 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'; From 69de2aad552a02a003d00deeef5f1895aa0091cb Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 11:42:07 -0500 Subject: [PATCH 12/29] Restyle emoji components --- .../components/ui/emoji-selector/emoji-selector.tsx | 13 ++++++------- app/soapbox/components/ui/emoji/emoji.tsx | 3 +-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx index 15fbe595a..d4006562e 100644 --- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import React from 'react'; -import { Emoji } from 'soapbox/components/ui'; +import { Emoji, HStack } from 'soapbox/components/ui'; interface IEmojiButton { emoji: string, @@ -13,7 +13,7 @@ interface IEmojiButton { const EmojiButton: React.FC = ({ emoji, className, onClick, tabIndex }): JSX.Element => { return ( ); }; @@ -36,10 +36,9 @@ const EmojiSelector: React.FC = ({ emojis, onReact, visible = fa }; return ( -
{emojis.map((emoji, i) => ( = ({ emojis, onReact, visible = fa tabIndex={(visible || focused) ? 0 : -1} /> ))} -
+ ); }; diff --git a/app/soapbox/components/ui/emoji/emoji.tsx b/app/soapbox/components/ui/emoji/emoji.tsx index d06b44d15..9bbaa0dec 100644 --- a/app/soapbox/components/ui/emoji/emoji.tsx +++ b/app/soapbox/components/ui/emoji/emoji.tsx @@ -1,4 +1,3 @@ -import classNames from 'classnames'; import React from 'react'; import { joinPublicPath } from 'soapbox/utils/static'; @@ -44,7 +43,7 @@ const Emoji: React.FC = ({ className, emoji }): JSX.Element | null => { return ( {emoji} From 1742236074ee0d409a1d0914b99811d68574674e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 13:31:08 -0500 Subject: [PATCH 13/29] First pass at Hoverable component --- app/soapbox/components/status_action_bar.js | 22 ++++----- .../components/ui/hoverable/hoverable.tsx | 49 +++++++++++++++++++ app/soapbox/components/ui/index.ts | 1 + 3 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 app/soapbox/components/ui/hoverable/hoverable.tsx diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index 367ae7b04..fc85da343 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -19,7 +19,7 @@ import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { openModal } from '../actions/modals'; -import { IconButton, Text } from './ui'; +import { IconButton, Text, Hoverable } from './ui'; const messages = defineMessages({ @@ -547,7 +547,6 @@ class StatusActionBar extends ImmutablePureComponent { render() { const { status, intl, allowedEmoji, emojiSelectorFocused, handleEmojiSelectorUnfocus, features, me } = this.props; - const { emojiSelectorVisible } = this.state; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -664,18 +663,17 @@ class StatusActionBar extends ImmutablePureComponent { {reblogCount !== 0 && {reblogCount}}
-
+ )} > - {emojiReactCount} ) )} -
+ {shareButton} diff --git a/app/soapbox/components/ui/hoverable/hoverable.tsx b/app/soapbox/components/ui/hoverable/hoverable.tsx new file mode 100644 index 000000000..cca3cd6df --- /dev/null +++ b/app/soapbox/components/ui/hoverable/hoverable.tsx @@ -0,0 +1,49 @@ +import Portal from '@reach/portal'; +import React, { useState, useRef } from 'react'; + +interface IHoverable { + component: React.Component, +} + +/** 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 handleMouseEnter = () => { + setPortalActive(true); + }; + + const handleMouseLeave = () => { + setPortalActive(false); + }; + + const setPortalPosition = (): React.CSSProperties => { + if (!ref.current) return {}; + + const { top, height, left, width } = ref.current.getBoundingClientRect(); + + return { + top: top + height, + left, + width, + }; + }; + + return ( +
+ {children} + {portalActive &&
{component}
} +
+ ); +}; + +export default Hoverable; diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index fa60595f5..3aa272def 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -7,6 +7,7 @@ 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'; +export { default as Hoverable } from './hoverable/hoverable'; export { default as HStack } from './hstack/hstack'; export { default as Icon } from './icon/icon'; export { default as IconButton } from './icon-button/icon-button'; From 6c8bc3f329e80d07c286d84454f7095c70a20ed8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 14:55:42 -0500 Subject: [PATCH 14/29] Hoverable component: we've come full circle --- app/soapbox/components/status_action_bar.js | 48 ++++++++++--------- .../ui/emoji-selector/emoji-selector.tsx | 2 +- .../components/ui/hoverable/hoverable.tsx | 40 +++++++++++----- 3 files changed, 54 insertions(+), 36 deletions(-) diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index fc85da343..6f144953a 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -663,30 +663,34 @@ class StatusActionBar extends ImmutablePureComponent { {reblogCount !== 0 && {reblogCount}}
- - )} > - + + )} + > + + + {emojiReactCount !== 0 && ( (features.exposableReactions && !features.emojiReacts) ? ( @@ -696,7 +700,7 @@ class StatusActionBar extends ImmutablePureComponent { {emojiReactCount} ) )} - +
{shareButton} diff --git a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx index d4006562e..aaa5b9349 100644 --- a/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx +++ b/app/soapbox/components/ui/emoji-selector/emoji-selector.tsx @@ -38,7 +38,7 @@ const EmojiSelector: React.FC = ({ emojis, onReact, visible = fa return ( {emojis.map((emoji, i) => ( = ({ }): JSX.Element => { const [portalActive, setPortalActive] = useState(false); + const ref = useRef(null); + const popperRef = useRef(null); const handleMouseEnter = () => { setPortalActive(true); @@ -22,17 +25,18 @@ const Hoverable: React.FC = ({ setPortalActive(false); }; - const setPortalPosition = (): React.CSSProperties => { - if (!ref.current) return {}; - - const { top, height, left, width } = ref.current.getBoundingClientRect(); - - return { - top: top + height, - left, - width, - }; - }; + const { styles, attributes } = usePopper(ref.current, popperRef.current, { + placement: 'top-start', + strategy: 'fixed', + modifiers: [ + { + name: 'offset', + options: { + offset: [-10, 0], + }, + }, + ], + }); return (
= ({ ref={ref} > {children} - {portalActive &&
{component}
} + +
+ {component} +
); }; From 0f044ad8e85116bb431ffac1d2e6a5f72ead7a9d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 15:45:48 -0500 Subject: [PATCH 15/29] Create StatusActionButton component --- .../components/status-action-button.tsx | 64 +++++++++++++++++++ app/soapbox/components/status_action_bar.js | 30 ++++----- .../components/ui/icon-button/icon-button.tsx | 2 +- 3 files changed, 77 insertions(+), 19 deletions(-) create mode 100644 app/soapbox/components/status-action-button.tsx diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx new file mode 100644 index 000000000..826df3f62 --- /dev/null +++ b/app/soapbox/components/status-action-button.tsx @@ -0,0 +1,64 @@ +import classNames from 'classnames'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { IconButton, Text } from 'soapbox/components/ui'; + +interface IStatusActionCounter { + count: number, + to?: string, +} + +/** Action button numerical counter, eg "5" likes */ +const StatusActionCounter: React.FC = ({ to, count = 0 }): JSX.Element => { + const text = {count}; + return to ? {text} : text; +}; + +interface IStatusActionButton { + icon: string, + onClick: () => void, + count: number, + active?: boolean, + title?: string, + to?: string, +} + +/** Status action container element */ +const StatusAction: React.FC = ({ children }) => { + return ( +
+ {children} +
+ ); +}; + +/** Action button (eg "Like") for a Status */ +const StatusActionButton: React.FC = ({ icon, title, to, active = false, onClick, count = 0 }): JSX.Element => { + + const handleClick: React.EventHandler = (e) => { + onClick(); + e.stopPropagation(); + e.preventDefault(); + }; + + return ( + + + + {count ? ( + + ) : null} + + ); +}; + +export { StatusAction, StatusActionButton, StatusActionCounter }; +export default StatusActionButton; diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index 6f144953a..45331fb38 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -10,6 +10,7 @@ import { Link, withRouter } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import EmojiSelector from 'soapbox/components/emoji_selector'; +import { StatusAction, StatusActionButton } from 'soapbox/components/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { isUserTouching } from 'soapbox/is_mobile'; import { isStaff, isAdmin } from 'soapbox/utils/accounts'; @@ -643,25 +644,18 @@ class StatusActionBar extends ImmutablePureComponent { return (
-
- + - {replyCount !== 0 ? ( - - {replyCount} - - ) : null} -
- -
+ {reblogButton} {reblogCount !== 0 && {reblogCount}} -
+
+ -
+
); } diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 467c8e4cd..2d8c27b7f 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -10,7 +10,7 @@ interface IIconButton { iconClassName?: string, disabled?: boolean, src: string, - onClick?: () => void, + onClick?: React.EventHandler, text?: string, title?: string, transparent?: boolean From 6cb04965a2b6f7b4f125a1f5a8b50ef98e4eab5c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 16:16:10 -0500 Subject: [PATCH 16/29] StatusActionBar: refactor buttons --- app/soapbox/components/status_action_bar.js | 48 +++++++++---------- .../{ => ui/status}/status-action-button.tsx | 20 ++++++-- 2 files changed, 40 insertions(+), 28 deletions(-) rename app/soapbox/components/{ => ui/status}/status-action-button.tsx (81%) diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index 45331fb38..e098713df 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -6,11 +6,15 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { defineMessages, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; -import { Link, withRouter } from 'react-router-dom'; +import { withRouter } from 'react-router-dom'; import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import EmojiSelector from 'soapbox/components/emoji_selector'; -import { StatusAction, StatusActionButton } from 'soapbox/components/status-action-button'; +import { + StatusAction, + StatusActionButton, + StatusActionCounter, +} from 'soapbox/components/ui/status/status-action-button'; import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; import { isUserTouching } from 'soapbox/is_mobile'; import { isStaff, isAdmin } from 'soapbox/utils/accounts'; @@ -20,7 +24,7 @@ import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { openModal } from '../actions/modals'; -import { IconButton, Text, Hoverable } from './ui'; +import { IconButton, Hoverable } from './ui'; const messages = defineMessages({ @@ -345,11 +349,9 @@ class StatusActionBar extends ImmutablePureComponent { 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')); } @@ -631,17 +633,6 @@ class StatusActionBar extends ImmutablePureComponent { const canShare = ('share' in navigator) && status.get('visibility') === 'public'; - const shareButton = canShare && ( -
- -
- ); - return (
{reblogButton} - {reblogCount !== 0 && {reblogCount}} + {reblogCount > 0 && ( + + )}
- {emojiReactCount !== 0 && ( + {emojiReactCount > 0 && ( (features.exposableReactions && !features.emojiReacts) ? ( - - {emojiReactCount} - + ) : ( - {emojiReactCount} + ) )}
- {shareButton} + {canShare && ( + + )} diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/ui/status/status-action-button.tsx similarity index 81% rename from app/soapbox/components/status-action-button.tsx rename to app/soapbox/components/ui/status/status-action-button.tsx index 826df3f62..79a7bedba 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/ui/status/status-action-button.tsx @@ -6,13 +6,26 @@ import { IconButton, Text } from 'soapbox/components/ui'; interface IStatusActionCounter { count: number, + onClick?: () => void, to?: string, } /** Action button numerical counter, eg "5" likes */ -const StatusActionCounter: React.FC = ({ to, count = 0 }): JSX.Element => { - const text = {count}; - return to ? {text} : text; +const StatusActionCounter: React.FC = ({ to = '#', onClick, count = 0 }): JSX.Element => { + + const handleClick: React.EventHandler = e => { + if (onClick) { + onClick(); + e.preventDefault(); + e.stopPropagation(); + } + }; + + return ( + + {count} + + ); }; interface IStatusActionButton { @@ -61,4 +74,3 @@ const StatusActionButton: React.FC = ({ icon, title, to, ac }; export { StatusAction, StatusActionButton, StatusActionCounter }; -export default StatusActionButton; From 3fe21ce268e429138af9c1714b42ba87c8533f29 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 17:38:36 -0500 Subject: [PATCH 17/29] StatusActionBar: conditionally render dumb Like button --- app/soapbox/components/status_action_bar.js | 87 ++++++++++--------- .../ui/status/status-action-button.tsx | 7 +- 2 files changed, 54 insertions(+), 40 deletions(-) diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index e098713df..6f9dfe35d 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -167,8 +167,6 @@ class StatusActionBar extends ImmutablePureComponent { handleLikeButtonClick = e => { const { features } = this.props; - e.stopPropagation(); - const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍'; if (features.emojiReacts && isUserTouching()) { @@ -650,45 +648,56 @@ class StatusActionBar extends ImmutablePureComponent { )} -
- - )} + {features.emojiReacts ? ( +
- - - - {emojiReactCount > 0 && ( - (features.exposableReactions && !features.emojiReacts) ? ( - + )} + > + - ) : ( - - ) - )} -
+
+ + {emojiReactCount > 0 && ( + (features.exposableReactions && !features.emojiReacts) ? ( + + ) : ( + + ) + )} +
+ ): ( + + )} {canShare && ( = ({ icon, title, to, ac src={icon} onClick={handleClick} className={classNames('text-gray-400 hover:text-gray-600 dark:hover:text-white', { - 'text-success-600 hover:text-success-600': active, + 'text-accent-300 hover:text-accent-300': active, + // TODO: repost button + // 'text-success-600 hover:text-success-600': active, + })} + iconClassName={classNames({ + 'fill-accent-300': active, })} /> From c59ff4e822afe8a3d5d87f3afbd58684b563307d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 17:45:09 -0500 Subject: [PATCH 18/29] StatusActionCounter: use shortNumberFormat --- app/soapbox/components/ui/status/status-action-button.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/soapbox/components/ui/status/status-action-button.tsx b/app/soapbox/components/ui/status/status-action-button.tsx index 7244bafed..350e84851 100644 --- a/app/soapbox/components/ui/status/status-action-button.tsx +++ b/app/soapbox/components/ui/status/status-action-button.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { IconButton, Text } from 'soapbox/components/ui'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; interface IStatusActionCounter { count: number, @@ -23,7 +24,7 @@ const StatusActionCounter: React.FC = ({ to = '#', onClick return ( - {count} + {shortNumberFormat(count)} ); }; From 4a8f08e3131c86ca09239d12ae6a96106127aa5d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 18:39:27 -0500 Subject: [PATCH 19/29] StatusActionBar: convert to tsx --- ...us_action_bar.js => status_action_bar.tsx} | 285 ++++++++++-------- .../ui/status/status-action-button.tsx | 2 +- app/soapbox/reducers/statuses.ts | 4 +- app/soapbox/types/soapbox.ts | 3 + app/soapbox/utils/accounts.ts | 6 +- .../{emoji_reacts.js => emoji_reacts.ts} | 53 ++-- 6 files changed, 191 insertions(+), 162 deletions(-) rename app/soapbox/components/{status_action_bar.js => status_action_bar.tsx} (74%) rename app/soapbox/utils/{emoji_reacts.js => emoji_reacts.ts} (57%) diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.tsx similarity index 74% rename from app/soapbox/components/status_action_bar.js rename to app/soapbox/components/status_action_bar.tsx index 6f9dfe35d..047544567 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.tsx @@ -1,10 +1,8 @@ import classNames from 'classnames'; import { List as ImmutableList } from 'immutable'; -import PropTypes from 'prop-types'; import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl } from 'react-intl'; +import { defineMessages, injectIntl, IntlShape } from 'react-intl'; import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; @@ -20,12 +18,16 @@ import { isUserTouching } from 'soapbox/is_mobile'; import { isStaff, isAdmin } from 'soapbox/utils/accounts'; import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; import { getFeatures } from 'soapbox/utils/features'; -import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; import { openModal } from '../actions/modals'; import { IconButton, Hoverable } from './ui'; +import type { History } from 'history'; +import type { AnyAction, Dispatch } from 'redux'; +import type { RootState } from 'soapbox/store'; +import type { Status } from 'soapbox/types/entities'; +import type { Features } from 'soapbox/utils/features'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -72,63 +74,70 @@ const messages = defineMessages({ quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, }); -class StatusActionBar extends ImmutablePureComponent { +interface IStatusActionBar { + status: Status, + onOpenUnauthorizedModal: (modalType?: string) => 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, +} - static propTypes = { - status: ImmutablePropTypes.record.isRequired, - onOpenUnauthorizedModal: PropTypes.func.isRequired, - onOpenReblogsModal: PropTypes.func.isRequired, - onReply: PropTypes.func, - onFavourite: PropTypes.func, - onBookmark: PropTypes.func, - onReblog: PropTypes.func, - onQuote: PropTypes.func, - onDelete: PropTypes.func, - onDirect: PropTypes.func, - onChat: PropTypes.func, - onMention: PropTypes.func, - onMute: PropTypes.func, - onBlock: PropTypes.func, - onReport: PropTypes.func, - onEmbed: PropTypes.func, - onDeactivateUser: PropTypes.func, - onDeleteUser: PropTypes.func, - onToggleStatusSensitivity: PropTypes.func, - onDeleteStatus: PropTypes.func, - onMuteConversation: PropTypes.func, - onPin: PropTypes.func, - withDismiss: PropTypes.bool, - withGroupAdmin: PropTypes.bool, - intl: PropTypes.object.isRequired, - me: SoapboxPropTypes.me, - isStaff: PropTypes.bool.isRequired, - isAdmin: PropTypes.bool.isRequired, - allowedEmoji: ImmutablePropTypes.list, - emojiSelectorFocused: PropTypes.bool, - handleEmojiSelectorUnfocus: PropTypes.func.isRequired, - features: PropTypes.object.isRequired, - history: PropTypes.object, - }; +interface IStatusActionBarState { + emojiSelectorVisible: boolean, +} - static defaultProps = { +class StatusActionBar extends ImmutablePureComponent { + + 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); @@ -137,9 +146,7 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleShareClick = (e) => { - e.stopPropagation(); - + handleShareClick = () => { navigator.share({ text: this.props.status.get('search_index'), url: this.props.status.get('url'), @@ -148,7 +155,7 @@ class StatusActionBar extends ImmutablePureComponent { }); } - handleLikeButtonHover = e => { + handleLikeButtonHover: React.EventHandler = () => { const { features } = this.props; if (features.emojiReacts && !isUserTouching()) { @@ -156,7 +163,7 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleLikeButtonLeave = e => { + handleLikeButtonLeave: React.EventHandler = () => { const { features } = this.props; if (features.emojiReacts && !isUserTouching()) { @@ -164,10 +171,11 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleLikeButtonClick = e => { + handleLikeButtonClick = () => { const { features } = this.props; - 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) { @@ -180,23 +188,23 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleReact = emoji => { + handleReact = (emoji: string): void => { const { me, dispatch, onOpenUnauthorizedModal, status } = this.props; if (me) { - dispatch(simpleEmojiReact(status, emoji)); + dispatch(simpleEmojiReact(status, emoji) as any); } else { onOpenUnauthorizedModal('FAVOURITE'); } this.setState({ emojiSelectorVisible: false }); } - handleReactClick = emoji => { - return e => { + handleReactClick = (emoji: string): React.EventHandler => { + return () => { this.handleReact(emoji); }; } - handleFavouriteClick = () => { + handleFavouriteClick: React.EventHandler = () => { const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; if (me) { onFavourite(status); @@ -205,12 +213,12 @@ class StatusActionBar extends ImmutablePureComponent { } } - 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(); @@ -221,7 +229,7 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleQuoteClick = (e) => { + handleQuoteClick: React.EventHandler = (e) => { e.stopPropagation(); const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; if (me) { @@ -231,47 +239,47 @@ 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); } - handleDirectClick = (e) => { + handleDirectClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onDirect(this.props.status.get('account'), this.props.history); } - handleChatClick = (e) => { + handleChatClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onChat(this.props.status.get('account'), this.props.history); } - handleMuteClick = (e) => { + handleMuteClick: React.EventHandler = (e) => { e.stopPropagation(); this.props.onMute(this.props.status.get('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')}`); } @@ -280,17 +288,17 @@ class StatusActionBar extends ImmutablePureComponent { 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) => { + handleCopy: React.EventHandler = (e) => { const url = this.props.status.get('url'); const textarea = document.createElement('textarea'); @@ -311,38 +319,38 @@ class StatusActionBar extends ImmutablePureComponent { } } - handleGroupRemoveAccount = (e) => { - const { status } = this.props; + // 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.get('id')); + // } - 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) => { + 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); } @@ -351,13 +359,14 @@ class StatusActionBar extends ImmutablePureComponent { const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props; 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; + _makeMenu = (publicStatus: boolean) => { + const { status, intl, withDismiss, me, features, isStaff, isAdmin } = this.props; const mutingConversation = status.get('muted'); const ownAccount = status.getIn(['account', 'id']) === me; + const username = String(status.getIn(['account', 'username'])); const menu = []; @@ -434,20 +443,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'), // }); @@ -455,17 +464,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'), }); @@ -476,16 +485,16 @@ 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(), + action: (event: Event) => event.stopPropagation(), }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/pleroma/admin/#/statuses/${status.get('id')}/`, icon: require('@tabler/icons/icons/pencil.svg'), - action: (event) => event.stopPropagation(), + action: (event: Event) => event.stopPropagation(), }); } @@ -497,12 +506,12 @@ class StatusActionBar extends ImmutablePureComponent { 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, @@ -516,32 +525,32 @@ 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 }); }); } @@ -555,9 +564,9 @@ class StatusActionBar extends ImmutablePureComponent { 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); @@ -599,6 +608,7 @@ class StatusActionBar extends ImmutablePureComponent { reblogButton = ( + /> as any )} > - +
); @@ -716,10 +733,9 @@ class StatusActionBar extends ImmutablePureComponent { } -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, @@ -729,15 +745,15 @@ const mapStateToProps = state => { }; }; -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, @@ -745,6 +761,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/ui/status/status-action-button.tsx b/app/soapbox/components/ui/status/status-action-button.tsx index 350e84851..210829a97 100644 --- a/app/soapbox/components/ui/status/status-action-button.tsx +++ b/app/soapbox/components/ui/status/status-action-button.tsx @@ -32,7 +32,7 @@ const StatusActionCounter: React.FC = ({ to = '#', onClick interface IStatusActionButton { icon: string, onClick: () => void, - count: number, + count?: number, active?: boolean, title?: string, to?: string, diff --git a/app/soapbox/reducers/statuses.ts b/app/soapbox/reducers/statuses.ts index 869b9de61..cd3316a16 100644 --- a/app/soapbox/reducers/statuses.ts +++ b/app/soapbox/reducers/statuses.ts @@ -211,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/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/accounts.ts b/app/soapbox/utils/accounts.ts index 3536cc966..aec0159e7 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -29,15 +29,15 @@ export const getAcct = (account: Account, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct ); -export const isStaff = (account: ImmutableMap = ImmutableMap()): boolean => ( +export const isStaff = (account: Account): boolean => ( [isAdmin, isModerator].some(f => f(account) === true) ); -export const isAdmin = (account: ImmutableMap): boolean => ( +export const isAdmin = (account: Account): boolean => ( account.getIn(['pleroma', 'is_admin']) === true ); -export const isModerator = (account: ImmutableMap): boolean => ( +export const isModerator = (account: Account): boolean => ( account.getIn(['pleroma', 'is_moderator']) === true ); 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..3b3fc05d9 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([ '👍', '❤️', '😆', '😮', '😢', '😩', -]; +]); -export const sortEmoji = emojiReacts => ( +type Account = ImmutableMap; +type EmojiReact = ImmutableMap; + +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,31 @@ 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 => { + return String(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']); + .getIn([0, 'name'], '')); }; -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 (emojiReact) { return emojiReacts.set(idx, emojiReact.merge({ count: emojiReact.get('count') + 1, me: true, @@ -102,12 +108,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({ From 1e3c6d943058d6cb1cf7e28c24771404b5c9b278 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 1 Apr 2022 19:35:57 -0500 Subject: [PATCH 20/29] Remove staff util functions, normalize account staff fields --- .../__snapshots__/emoji_selector-test.js.snap | 106 +++++++++-------- app/soapbox/components/profile_hover_card.js | 5 +- app/soapbox/components/sidebar-navigation.tsx | 2 +- app/soapbox/components/sidebar_menu.js | 5 +- app/soapbox/components/status_action_bar.tsx | 5 +- app/soapbox/components/thumb_navigation.tsx | 4 +- .../features/account/components/header.js | 18 ++- .../containers/header_container.js | 3 +- app/soapbox/features/admin/index.js | 5 +- .../features/status/components/action_bar.js | 5 +- .../components/instance_moderation_panel.js | 9 +- .../ui/components/profile-dropdown.tsx | 5 +- .../ui/components/profile_info_panel.js | 10 +- app/soapbox/features/ui/index.js | 5 +- .../features/ui/util/react_router_helpers.js | 5 +- .../normalizers/__tests__/account-test.js | 9 ++ app/soapbox/normalizers/account.ts | 16 +++ app/soapbox/pages/remote_instance_page.js | 7 +- app/soapbox/utils/__tests__/accounts-test.js | 109 +++--------------- app/soapbox/utils/accounts.ts | 14 +-- 20 files changed, 135 insertions(+), 212 deletions(-) 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 154cc5603..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/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/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 && (
- {isAdmin(account) && } + {account.admin && }

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