diff --git a/.eslintrc.js b/.eslintrc.js index 7fa666e36..164949e65 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -78,7 +78,6 @@ module.exports = { 'space-infix-ops': 'error', 'space-in-parens': ['error', 'never'], 'keyword-spacing': 'error', - 'consistent-return': 'error', 'dot-notation': 'error', eqeqeq: 'error', indent: ['error', 2, { @@ -278,7 +277,6 @@ module.exports = { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // https://stackoverflow.com/a/69155899 - 'consistent-return': 'off', }, parser: '@typescript-eslint/parser', }, diff --git a/app/soapbox/actions/compose.ts b/app/soapbox/actions/compose.ts index ad8439307..f33a9fa18 100644 --- a/app/soapbox/actions/compose.ts +++ b/app/soapbox/actions/compose.ts @@ -96,6 +96,8 @@ const messages = defineMessages({ uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' }, uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' }, view: { id: 'snackbar.view', defaultMessage: 'View' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1); @@ -144,6 +146,20 @@ const replyCompose = (status: Status) => dispatch(openModal('COMPOSE')); }; +const replyComposeWithConfirmation = (status: Status, intl: IntlShape) => + (dispatch: AppDispatch, getState: () => RootState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status)), + })); + } else { + dispatch(replyCompose(status)); + } + }; + const cancelReplyCompose = () => ({ type: COMPOSE_REPLY_CANCEL, }); @@ -739,6 +755,7 @@ export { setComposeToStatus, changeCompose, replyCompose, + replyComposeWithConfirmation, cancelReplyCompose, quoteCompose, cancelQuoteCompose, diff --git a/app/soapbox/actions/interactions.ts b/app/soapbox/actions/interactions.ts index ff449e03c..70fd93317 100644 --- a/app/soapbox/actions/interactions.ts +++ b/app/soapbox/actions/interactions.ts @@ -94,6 +94,15 @@ const unreblog = (status: StatusEntity) => }); }; +const toggleReblog = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.reblogged) { + dispatch(unreblog(status)); + } else { + dispatch(reblog(status)); + } + }; + const reblogRequest = (status: StatusEntity) => ({ type: REBLOG_REQUEST, status: status, @@ -158,6 +167,16 @@ const unfavourite = (status: StatusEntity) => }); }; +const toggleFavourite = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.favourited) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + const favouriteRequest = (status: StatusEntity) => ({ type: FAVOURITE_REQUEST, status: status, @@ -222,6 +241,15 @@ const unbookmark = (status: StatusEntity) => }); }; +const toggleBookmark = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.bookmarked) { + dispatch(unbookmark(status)); + } else { + dispatch(bookmark(status)); + } + }; + const bookmarkRequest = (status: StatusEntity) => ({ type: BOOKMARK_REQUEST, status: status, @@ -394,6 +422,15 @@ const unpin = (status: StatusEntity) => }); }; +const togglePin = (status: StatusEntity) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.pinned) { + dispatch(unpin(status)); + } else { + dispatch(pin(status)); + } + }; + const unpinRequest = (status: StatusEntity) => ({ type: UNPIN_REQUEST, status, @@ -488,6 +525,7 @@ export { REMOTE_INTERACTION_FAIL, reblog, unreblog, + toggleReblog, reblogRequest, reblogSuccess, reblogFail, @@ -496,6 +534,7 @@ export { unreblogFail, favourite, unfavourite, + toggleFavourite, favouriteRequest, favouriteSuccess, favouriteFail, @@ -504,6 +543,7 @@ export { unfavouriteFail, bookmark, unbookmark, + toggleBookmark, bookmarkRequest, bookmarkSuccess, bookmarkFail, @@ -530,6 +570,7 @@ export { unpinRequest, unpinSuccess, unpinFail, + togglePin, remoteInteraction, remoteInteractionRequest, remoteInteractionSuccess, diff --git a/app/soapbox/actions/statuses.ts b/app/soapbox/actions/statuses.ts index 9df7f207b..db15e7a21 100644 --- a/app/soapbox/actions/statuses.ts +++ b/app/soapbox/actions/statuses.ts @@ -10,7 +10,7 @@ import { openModal } from './modals'; import { deleteFromTimelines } from './timelines'; import type { AppDispatch, RootState } from 'soapbox/store'; -import type { APIEntity } from 'soapbox/types/entities'; +import type { APIEntity, Status } from 'soapbox/types/entities'; const STATUS_CREATE_REQUEST = 'STATUS_CREATE_REQUEST'; const STATUS_CREATE_SUCCESS = 'STATUS_CREATE_SUCCESS'; @@ -266,6 +266,15 @@ const unmuteStatus = (id: string) => }); }; +const toggleMuteStatus = (status: Status) => + (dispatch: AppDispatch, getState: () => RootState) => { + if (status.muted) { + dispatch(unmuteStatus(status.id)); + } else { + dispatch(muteStatus(status.id)); + } + }; + const hideStatus = (ids: string[] | string) => { if (!Array.isArray(ids)) { ids = [ids]; @@ -288,6 +297,14 @@ const revealStatus = (ids: string[] | string) => { }; }; +const toggleStatusHidden = (status: Status) => { + if (status.hidden) { + return revealStatus(status.id); + } else { + return hideStatus(status.id); + } +}; + export { STATUS_CREATE_REQUEST, STATUS_CREATE_SUCCESS, @@ -324,6 +341,8 @@ export { fetchStatusWithContext, muteStatus, unmuteStatus, + toggleMuteStatus, hideStatus, revealStatus, + toggleStatusHidden, }; diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx new file mode 100644 index 000000000..0e6786fb6 --- /dev/null +++ b/app/soapbox/components/status-action-bar.tsx @@ -0,0 +1,660 @@ +import classNames from 'classnames'; +import { List as ImmutableList } from 'immutable'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { blockAccount } from 'soapbox/actions/accounts'; +import { showAlertForError } from 'soapbox/actions/alerts'; +import { launchChat } from 'soapbox/actions/chats'; +import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose'; +import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions'; +import { openModal } from 'soapbox/actions/modals'; +import { deactivateUserModal, deleteStatusModal, deleteUserModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; +import { initMuteModal } from 'soapbox/actions/mutes'; +import { initReport } from 'soapbox/actions/reports'; +import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; +import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; +import StatusActionButton from 'soapbox/components/status-action-button'; +import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; +import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; + +import type { Menu } from 'soapbox/components/dropdown_menu'; +import type { Account, Status } from 'soapbox/types/entities'; + +const messages = defineMessages({ + delete: { id: 'status.delete', defaultMessage: 'Delete' }, + redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, + edit: { id: 'status.edit', defaultMessage: 'Edit' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, + mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + share: { id: 'status.share', defaultMessage: 'Share' }, + more: { id: 'status.more', defaultMessage: 'More' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Like' }, + open: { id: 'status.open', defaultMessage: 'Expand this post' }, + bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, + unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, + report: { id: 'status.report', defaultMessage: 'Report @{name}' }, + muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, + unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, + unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, + embed: { id: 'status.embed', defaultMessage: 'Embed' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, + copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, + group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, + group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, + deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, + deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, + deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, + markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, + markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, + reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, + reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, + reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, + reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, + reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, + reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, + quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, + deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, + deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, + deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, + redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, + redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, +}); + +interface IStatusActionBar { + status: Status, + withDismiss?: boolean, + withLabels?: boolean, + expandable?: boolean, + space?: 'expand' | 'compact', +} + +const StatusActionBar: React.FC = ({ + status, + withDismiss = false, + withLabels = false, + expandable = true, + space = 'compact', +}) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const me = useAppSelector(state => state.me); + const features = useFeatures(); + const settings = useSettings(); + const soapboxConfig = useSoapboxConfig(); + + const { allowedEmoji } = soapboxConfig; + + const account = useOwnAccount(); + const isStaff = account ? account.staff : false; + const isAdmin = account ? account.admin : false; + + if (!status) { + return null; + } + + const onOpenUnauthorizedModal = (action?: string) => { + dispatch(openModal('UNAUTHORIZED', { + action, + ap_id: status.url, + })); + }; + + const handleReplyClick: React.MouseEventHandler = (e) => { + if (me) { + dispatch((_, getState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(status)), + })); + } else { + dispatch(replyCompose(status)); + } + }); + } else { + onOpenUnauthorizedModal('REPLY'); + } + + e.stopPropagation(); + }; + + const handleShareClick = () => { + navigator.share({ + text: status.search_index, + url: status.uri, + }).catch((e) => { + if (e.name !== 'AbortError') console.error(e); + }); + }; + + const handleFavouriteClick: React.EventHandler = (e) => { + if (me) { + dispatch(toggleFavourite(status)); + } else { + onOpenUnauthorizedModal('FAVOURITE'); + } + + e.stopPropagation(); + }; + + const handleBookmarkClick: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(toggleBookmark(status)); + }; + + const handleReblogClick: React.EventHandler = e => { + e.stopPropagation(); + + if (me) { + const modalReblog = () => dispatch(toggleReblog(status)); + const boostModal = settings.get('boostModal'); + if ((e && e.shiftKey) || !boostModal) { + modalReblog(); + } else { + dispatch(openModal('BOOST', { status, onReblog: modalReblog })); + } + } else { + onOpenUnauthorizedModal('REBLOG'); + } + }; + + const handleQuoteClick: React.EventHandler = (e) => { + e.stopPropagation(); + + if (me) { + dispatch((_, getState) => { + const state = getState(); + if (state.compose.text.trim().length !== 0) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(quoteCompose(status)), + })); + } else { + dispatch(quoteCompose(status)); + } + }); + } else { + onOpenUnauthorizedModal('REBLOG'); + } + }; + + const doDeleteStatus = (withRedraft = false) => { + dispatch((_, getState) => { + const deleteModal = settings.get('deleteModal'); + if (!deleteModal) { + dispatch(deleteStatus(status.id, withRedraft)); + } else { + dispatch(openModal('CONFIRM', { + icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), + heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), + message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), + confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), + onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)), + })); + } + }); + }; + + const handleDeleteClick: React.EventHandler = (e) => { + e.stopPropagation(); + doDeleteStatus(); + }; + + const handleRedraftClick: React.EventHandler = (e) => { + e.stopPropagation(); + doDeleteStatus(true); + }; + + const handleEditClick: React.EventHandler = () => { + dispatch(editStatus(status.id)); + }; + + const handlePinClick: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(togglePin(status)); + }; + + const handleMentionClick: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(mentionCompose(status.account as Account)); + }; + + const handleDirectClick: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(directCompose(status.account as Account)); + }; + + const handleChatClick: React.EventHandler = (e) => { + e.stopPropagation(); + const account = status.account as Account; + dispatch(launchChat(account.id, history)); + }; + + const handleMuteClick: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(initMuteModal(status.account as Account)); + }; + + const handleBlockClick: React.EventHandler = (e) => { + e.stopPropagation(); + + const account = status.get('account') as Account; + dispatch(openModal('CONFIRM', { + icon: require('@tabler/icons/ban.svg'), + heading: , + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.blockConfirm), + onConfirm: () => dispatch(blockAccount(account.id)), + secondary: intl.formatMessage(messages.blockAndReport), + onSecondary: () => { + dispatch(blockAccount(account.id)); + dispatch(initReport(account, status)); + }, + })); + }; + + const handleOpen: React.EventHandler = (e) => { + e.stopPropagation(); + history.push(`/@${status.getIn(['account', 'acct'])}/posts/${status.id}`); + }; + + const handleEmbed = () => { + dispatch(openModal('EMBED', { + url: status.get('url'), + onError: (error: any) => dispatch(showAlertForError(error)), + })); + }; + + const handleReport: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(initReport(status.account as Account, status)); + }; + + const handleConversationMuteClick: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(toggleMuteStatus(status)); + }; + + const handleCopy: React.EventHandler = (e) => { + const { url } = status; + const textarea = document.createElement('textarea'); + + e.stopPropagation(); + + textarea.textContent = url; + textarea.style.position = 'fixed'; + + document.body.appendChild(textarea); + + try { + textarea.select(); + document.execCommand('copy'); + } catch { + // Do nothing + } finally { + document.body.removeChild(textarea); + } + }; + + const handleDeactivateUser: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); + }; + + const handleDeleteUser: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string)); + }; + + const handleDeleteStatus: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(deleteStatusModal(intl, status.id)); + }; + + const handleToggleStatusSensitivity: React.EventHandler = (e) => { + e.stopPropagation(); + dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); + }; + + const _makeMenu = (publicStatus: boolean) => { + const mutingConversation = status.muted; + const ownAccount = status.getIn(['account', 'id']) === me; + const username = String(status.getIn(['account', 'username'])); + + const menu: Menu = []; + + if (expandable) { + menu.push({ + text: intl.formatMessage(messages.open), + action: handleOpen, + icon: require('@tabler/icons/arrows-vertical.svg'), + }); + } + + if (publicStatus) { + menu.push({ + text: intl.formatMessage(messages.copy), + action: handleCopy, + icon: require('@tabler/icons/link.svg'), + }); + + if (features.embeds) { + menu.push({ + text: intl.formatMessage(messages.embed), + action: handleEmbed, + icon: require('@tabler/icons/share.svg'), + }); + } + } + + if (!me) { + return menu; + } + + if (features.bookmarks) { + menu.push({ + text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), + action: handleBookmarkClick, + icon: status.bookmarked ? require('@tabler/icons/bookmark-off.svg') : require('@tabler/icons/bookmark.svg'), + }); + } + + menu.push(null); + + if (ownAccount || withDismiss) { + menu.push({ + text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), + action: handleConversationMuteClick, + icon: mutingConversation ? require('@tabler/icons/bell.svg') : require('@tabler/icons/bell-off.svg'), + }); + menu.push(null); + } + + if (ownAccount) { + if (publicStatus) { + menu.push({ + text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), + action: handlePinClick, + icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), + }); + } else { + if (status.visibility === 'private') { + menu.push({ + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), + action: handleReblogClick, + icon: require('@tabler/icons/repeat.svg'), + }); + } + } + + menu.push({ + text: intl.formatMessage(messages.delete), + action: handleDeleteClick, + icon: require('@tabler/icons/trash.svg'), + destructive: true, + }); + if (features.editStatuses) { + menu.push({ + text: intl.formatMessage(messages.edit), + action: handleEditClick, + icon: require('@tabler/icons/edit.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.redraft), + action: handleRedraftClick, + icon: require('@tabler/icons/edit.svg'), + destructive: true, + }); + } + } else { + menu.push({ + text: intl.formatMessage(messages.mention, { name: username }), + action: handleMentionClick, + icon: require('@tabler/icons/at.svg'), + }); + + if (status.getIn(['account', 'pleroma', 'accepts_chat_messages']) === true) { + menu.push({ + text: intl.formatMessage(messages.chat, { name: username }), + action: handleChatClick, + icon: require('@tabler/icons/messages.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.direct, { name: username }), + action: handleDirectClick, + icon: require('@tabler/icons/mail.svg'), + }); + } + + menu.push(null); + menu.push({ + text: intl.formatMessage(messages.mute, { name: username }), + action: handleMuteClick, + icon: require('@tabler/icons/circle-x.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.block, { name: username }), + action: handleBlockClick, + icon: require('@tabler/icons/ban.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.report, { name: username }), + action: handleReport, + icon: require('@tabler/icons/flag.svg'), + }); + } + + if (isStaff) { + menu.push(null); + + if (isAdmin) { + menu.push({ + text: intl.formatMessage(messages.admin_account, { name: username }), + href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, + icon: require('@tabler/icons/gavel.svg'), + action: (event) => event.stopPropagation(), + }); + menu.push({ + text: intl.formatMessage(messages.admin_status), + href: `/pleroma/admin/#/statuses/${status.id}/`, + icon: require('@tabler/icons/pencil.svg'), + action: (event) => event.stopPropagation(), + }); + } + + menu.push({ + text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), + action: handleToggleStatusSensitivity, + icon: require('@tabler/icons/alert-triangle.svg'), + }); + + if (!ownAccount) { + menu.push({ + text: intl.formatMessage(messages.deactivateUser, { name: username }), + action: handleDeactivateUser, + icon: require('@tabler/icons/user-off.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.deleteUser, { name: username }), + action: handleDeleteUser, + icon: require('@tabler/icons/user-minus.svg'), + destructive: true, + }); + menu.push({ + text: intl.formatMessage(messages.deleteStatus), + action: handleDeleteStatus, + icon: require('@tabler/icons/trash.svg'), + destructive: true, + }); + } + } + + return menu; + }; + + const publicStatus = ['public', 'unlisted'].includes(status.visibility); + + const replyCount = status.replies_count; + const reblogCount = status.reblogs_count; + const favouriteCount = status.favourites_count; + + const emojiReactCount = reduceEmoji( + (status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList, + favouriteCount, + status.favourited, + allowedEmoji, + ).reduce((acc, cur) => acc + cur.get('count'), 0); + + const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; + + const reactMessages = { + '👍': messages.reactionLike, + '❤️': messages.reactionHeart, + '😆': messages.reactionLaughing, + '😮': messages.reactionOpenMouth, + '😢': messages.reactionCry, + '😩': messages.reactionWeary, + '': messages.favourite, + }; + + const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); + + const menu = _makeMenu(publicStatus); + let reblogIcon = require('@tabler/icons/repeat.svg'); + let replyTitle; + + if (status.visibility === 'direct') { + reblogIcon = require('@tabler/icons/mail.svg'); + } else if (status.visibility === 'private') { + reblogIcon = require('@tabler/icons/lock.svg'); + } + + const reblogMenu = [{ + text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), + action: handleReblogClick, + icon: require('@tabler/icons/repeat.svg'), + }, { + text: intl.formatMessage(messages.quotePost), + action: handleQuoteClick, + icon: require('@tabler/icons/quote.svg'), + }]; + + const reblogButton = ( + + ); + + if (!status.in_reply_to_id) { + replyTitle = intl.formatMessage(messages.reply); + } else { + replyTitle = intl.formatMessage(messages.replyAll); + } + + const canShare = ('share' in navigator) && status.visibility === 'public'; + + return ( +
+ + + {(features.quotePosts && me) ? ( + + {reblogButton} + + ) : ( + reblogButton + )} + + {features.emojiReacts ? ( + + + + ) : ( + + )} + + {canShare && ( + + )} + + + + +
+ ); +}; + +export default StatusActionBar; diff --git a/app/soapbox/components/status-action-button.tsx b/app/soapbox/components/status-action-button.tsx index 1a625b5a1..2d41fe5b7 100644 --- a/app/soapbox/components/status-action-button.tsx +++ b/app/soapbox/components/status-action-button.tsx @@ -32,34 +32,21 @@ interface IStatusActionButton extends React.ButtonHTMLAttributes((props, ref): JSX.Element => { - const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, ...filteredProps } = props; + const { icon, className, iconClassName, active, color, filled = false, count = 0, emoji, text, ...filteredProps } = props; - return ( - ); }); diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index e3dce38f5..37cf7a24b 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -1,26 +1,28 @@ import classNames from 'classnames'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { injectIntl, FormattedMessage, IntlShape, defineMessages } from 'react-intl'; -import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'; +import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; +import { NavLink, useHistory } from 'react-router-dom'; +import { mentionCompose, replyComposeWithConfirmation } from 'soapbox/actions/compose'; +import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; +import { openModal } from 'soapbox/actions/modals'; +import { toggleStatusHidden } from 'soapbox/actions/statuses'; import Icon from 'soapbox/components/icon'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; -import { defaultMediaVisibility } from 'soapbox/utils/status'; +import { useAppDispatch, useSettings } from 'soapbox/hooks'; +import { defaultMediaVisibility, textForScreenReader, getActualStatus } from 'soapbox/utils/status'; +import StatusActionBar from './status-action-bar'; import StatusMedia from './status-media'; import StatusReplyMentions from './status-reply-mentions'; -import StatusActionBar from './status_action_bar'; import StatusContent from './status_content'; import { HStack, Text } from './ui'; -import type { History } from 'history'; -import type { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import type { Map as ImmutableMap } from 'immutable'; import type { Account as AccountEntity, - Attachment as AttachmentEntity, Status as StatusEntity, } from 'soapbox/types/entities'; @@ -31,508 +33,355 @@ const messages = defineMessages({ reblogged_by: { id: 'status.reblogged_by', defaultMessage: '{name} reposted' }, }); -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 ? 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']), - ]; - - if (rebloggedByText) { - values.push(rebloggedByText); - } - - return values.join(', '); -}; - -interface IStatus extends RouteComponentProps { +export interface IStatus { id?: string, - contextType?: string, - intl: IntlShape, status: StatusEntity, - account: AccountEntity, - otherAccounts: ImmutableList, - onClick: () => void, - onReply: (status: StatusEntity) => void, - onFavourite: (status: StatusEntity) => void, - onReblog: (status: StatusEntity, e?: KeyboardEvent) => void, - onQuote: (status: StatusEntity) => void, - onDelete: (status: StatusEntity) => void, - onEdit: (status: StatusEntity) => void, - onDirect: (status: StatusEntity) => void, - onChat: (status: StatusEntity) => void, - onMention: (account: StatusEntity['account']) => 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?: boolean) => void, - onMoveDown: (statusId: string, featured?: boolean) => void, - getScrollPosition?: () => ScrollPosition | undefined, - updateScrollBottom?: (bottom: number) => void, - group: ImmutableMap, - displayMedia: string, - allowedEmoji: ImmutableList, - focusable: boolean, - history: History, + onClick?: () => void, + muted?: boolean, + hidden?: boolean, + unread?: boolean, + onMoveUp?: (statusId: string, featured?: boolean) => void, + onMoveDown?: (statusId: string, featured?: boolean) => void, + group?: ImmutableMap, + focusable?: boolean, featured?: boolean, - withDismiss?: boolean, hideActionBar?: boolean, hoverable?: boolean, } -interface IStatusState { - showMedia: boolean, - statusId?: string, - emojiSelectorFocused: boolean, -} +const Status: React.FC = (props) => { + const { + status, + focusable = true, + hoverable = true, + onClick, + onMoveUp, + onMoveDown, + muted, + hidden, + featured, + unread, + group, + hideActionBar, + } = props; + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); -class Status extends ImmutablePureComponent { + const settings = useSettings(); + const displayMedia = settings.get('displayMedia') as string; + const didShowCard = useRef(false); + const node = useRef(null); - static defaultProps = { - focusable: true, - hoverable: true, + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + + const actualStatus = getActualStatus(status); + + // Track height changes we know about to compensate scrolling. + useEffect(() => { + didShowCard.current = Boolean(!muted && !hidden && status?.card); + }, []); + + useEffect(() => { + setShowMedia(defaultMediaVisibility(status, displayMedia)); + }, [status.id]); + + const handleToggleMediaVisibility = (): void => { + setShowMedia(!showMedia); }; - 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: any[] = [ - 'status', - 'account', - 'muted', - 'hidden', - ]; - - state: IStatusState = { - showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), - statusId: undefined, - emojiSelectorFocused: false, - }; - - // Track height changes we know about to compensate scrolling - componentDidMount(): void { - this.didShowCard = Boolean(!this.props.muted && !this.props.hidden && this.props.status && this.props.status.card); - } - - getSnapshotBeforeUpdate(): ScrollPosition | null { - if (this.props.getScrollPosition) { - return this.props.getScrollPosition() || null; + const handleClick = (): void => { + if (onClick) { + onClick(); } else { - return null; + history.push(`/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`); } - } - - static getDerivedStateFromProps(nextProps: IStatus, prevState: IStatusState) { - if (nextProps.status && nextProps.status.id !== prevState.statusId) { - return { - showMedia: defaultMediaVisibility(nextProps.status, nextProps.displayMedia), - statusId: nextProps.status.id, - }; - } else { - return null; - } - } - - // Compensate height changes - 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 && this.props.updateScrollBottom) { - if (this.node && this.node.offsetTop < snapshot.top) { - this.props.updateScrollBottom(snapshot.height - snapshot.top); - } - } - } - } - - componentWillUnmount(): void { - // FIXME: Run this code only when a status is being deleted. - // - // const { getScrollPosition, updateScrollBottom } = this.props; - // - // if (this.node && getScrollPosition && updateScrollBottom) { - // const position = getScrollPosition(); - // if (position && this.node.offsetTop < position.top) { - // requestAnimationFrame(() => { - // updateScrollBottom(position.height - position.top); - // }); - // } - // } - } - - handleToggleMediaVisibility = (): void => { - this.setState({ showMedia: !this.state.showMedia }); - } - - handleClick = (): void => { - if (this.props.onClick) { - this.props.onClick(); - return; - } - - if (!this.props.history) { - return; - } - - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); - } - - 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().id}`); - } - } - - handleExpandedToggle = (): void => { - this.props.onToggleHidden(this._properStatus()); }; - handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { - const { onOpenMedia, onOpenVideo } = this.props; - const status = this._properStatus(); + const handleExpandedToggle = (): void => { + dispatch(toggleStatusHidden(actualStatus)); + }; + + const handleHotkeyOpenMedia = (e?: KeyboardEvent): void => { + const status = actualStatus; const firstAttachment = status.media_attachments.first(); e?.preventDefault(); if (firstAttachment) { if (firstAttachment.type === 'video') { - onOpenVideo(firstAttachment, 0); + dispatch(openModal('VIDEO', { media: firstAttachment, time: 0 })); } else { - onOpenMedia(status.media_attachments, 0); + dispatch(openModal('MEDIA', { media: status.media_attachments, index: 0 })); } } - } + }; - handleHotkeyReply = (e?: KeyboardEvent): void => { + const handleHotkeyReply = (e?: KeyboardEvent): void => { e?.preventDefault(); - this.props.onReply(this._properStatus()); - } + dispatch(replyComposeWithConfirmation(actualStatus, intl)); + }; - handleHotkeyFavourite = (): void => { - this.props.onFavourite(this._properStatus()); - } + const handleHotkeyFavourite = (): void => { + toggleFavourite(actualStatus); + }; - handleHotkeyBoost = (e?: KeyboardEvent): void => { - this.props.onReblog(this._properStatus(), e); - } - - handleHotkeyMention = (e?: KeyboardEvent): void => { - e?.preventDefault(); - this.props.onMention(this._properStatus().account); - } - - handleHotkeyOpen = (): void => { - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}/posts/${this._properStatus().id}`); - } - - handleHotkeyOpenProfile = (): void => { - this.props.history.push(`/@${this._properStatus().getIn(['account', 'acct'])}`); - } - - handleHotkeyMoveUp = (e?: KeyboardEvent): void => { - this.props.onMoveUp(this.props.status.id, this.props.featured); - } - - handleHotkeyMoveDown = (e?: KeyboardEvent): void => { - this.props.onMoveDown(this.props.status.id, this.props.featured); - } - - handleHotkeyToggleHidden = (): void => { - this.props.onToggleHidden(this._properStatus()); - } - - handleHotkeyToggleSensitive = (): void => { - this.handleToggleMediaVisibility(); - } - - handleHotkeyReact = (): void => { - this._expandEmojiSelector(); - } - - handleEmojiSelectorExpand: React.EventHandler = e => { - if (e.key === 'Enter') { - this._expandEmojiSelector(); + const handleHotkeyBoost = (e?: KeyboardEvent): void => { + const modalReblog = () => dispatch(toggleReblog(actualStatus)); + const boostModal = settings.get('boostModal'); + if ((e && e.shiftKey) || !boostModal) { + modalReblog(); + } else { + dispatch(openModal('BOOST', { status: actualStatus, onReblog: modalReblog })); } - e.preventDefault(); - } + }; - handleEmojiSelectorUnfocus = (): void => { - this.setState({ emojiSelectorFocused: false }); - } + const handleHotkeyMention = (e?: KeyboardEvent): void => { + e?.preventDefault(); + dispatch(mentionCompose(actualStatus.account as AccountEntity)); + }; - _expandEmojiSelector = (): void => { - this.setState({ emojiSelectorFocused: true }); - const firstEmoji: HTMLDivElement | null | undefined = this.node?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + const handleHotkeyOpen = (): void => { + history.push(`/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`); + }; + + const handleHotkeyOpenProfile = (): void => { + history.push(`/@${actualStatus.getIn(['account', 'acct'])}`); + }; + + const handleHotkeyMoveUp = (e?: KeyboardEvent): void => { + if (onMoveUp) { + onMoveUp(status.id, featured); + } + }; + + const handleHotkeyMoveDown = (e?: KeyboardEvent): void => { + if (onMoveDown) { + onMoveDown(status.id, featured); + } + }; + + const handleHotkeyToggleHidden = (): void => { + dispatch(toggleStatusHidden(actualStatus)); + }; + + const handleHotkeyToggleSensitive = (): void => { + handleToggleMediaVisibility(); + }; + + const handleHotkeyReact = (): void => { + _expandEmojiSelector(); + }; + + const _expandEmojiSelector = (): void => { + const firstEmoji: HTMLDivElement | null | undefined = node.current?.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); firstEmoji?.focus(); }; - _properStatus(): StatusEntity { - const { status } = this.props; + if (!status) return null; + let prepend, rebloggedByText, reblogElement, reblogElementMobile; - if (status.reblog && typeof status.reblog === 'object') { - return status.reblog; - } else { - return status; - } + if (hidden) { + return ( +
+ {actualStatus.getIn(['account', 'display_name']) || actualStatus.getIn(['account', 'username'])} + {actualStatus.content} +
+ ); } - handleRef = (c: HTMLDivElement): void => { - this.node = c; - } - - render() { - const poll = null; - let prepend, rebloggedByText, reblogElement, reblogElementMobile; - - const { intl, hidden, featured, unread, group } = this.props; - - // FIXME: why does this need to reassign status and account?? - let { status, account, ...other } = this.props; // eslint-disable-line prefer-const - - if (!status) return null; - - if (hidden) { - return ( -
- {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {status.content} -
- ); - } - - if (status.filtered || status.getIn(['reblog', 'filtered'])) { - const minHandlers = this.props.muted ? undefined : { - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - }; - - return ( - -
- -
-
- ); - } - - if (featured) { - prepend = ( -
- - - - - - - -
- ); - } - - if (status.reblog && typeof status.reblog === 'object') { - const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) }; - - reblogElement = ( - event.stopPropagation()} - className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' - > - - - - - - , - }} - /> - - - ); - - reblogElementMobile = ( -
- event.stopPropagation()} - className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' - > - - - - - - , - }} - /> - - -
- ); - - rebloggedByText = intl.formatMessage( - messages.reblogged_by, - { name: String(status.getIn(['account', 'acct'])) }, - ); - - // @ts-ignore what the FUCK - account = status.account; - status = status.reblog; - } - - let quote; - - if (status.quote) { - if (status.pleroma.get('quote_visible', true) === false) { - quote = ( -
-

-
- ); - } else { - quote = ; - } - } - - const handlers = this.props.muted ? undefined : { - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - open: this.handleHotkeyOpen, - openProfile: this.handleHotkeyOpenProfile, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - toggleHidden: this.handleHotkeyToggleHidden, - toggleSensitive: this.handleHotkeyToggleSensitive, - openMedia: this.handleHotkeyOpenMedia, - react: this.handleHotkeyReact, + if (status.filtered || actualStatus.filtered) { + const minHandlers = muted ? undefined : { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, }; - const statusUrl = `/@${status.getIn(['account', 'acct'])}/posts/${status.id}`; - // const favicon = status.getIn(['account', 'pleroma', 'favicon']); - // const domain = getDomain(status.account); - return ( - -
this.props.history.push(statusUrl)} - role='link' - > - {prepend} - -
- {reblogElementMobile} - -
- -
- -
- {!group && status.group && ( -
- Posted in {String(status.getIn(['group', 'title']))} -
- )} - - - - - - - - {poll} - {quote} - - {!this.props.hideActionBar && ( - - )} -
-
+ +
+
); } -} + if (featured) { + prepend = ( +
+ + -export default withRouter(injectIntl(Status)); + + + + +
+ ); + } + + if (status.reblog && typeof status.reblog === 'object') { + const displayNameHtml = { __html: String(status.getIn(['account', 'display_name_html'])) }; + + reblogElement = ( + event.stopPropagation()} + className='hidden sm:flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' + > + + + + + + , + }} + /> + + + ); + + reblogElementMobile = ( +
+ event.stopPropagation()} + className='flex items-center text-gray-700 dark:text-gray-600 text-xs font-medium space-x-1 hover:underline' + > + + + + + + , + }} + /> + + +
+ ); + + rebloggedByText = intl.formatMessage( + messages.reblogged_by, + { name: String(status.getIn(['account', 'acct'])) }, + ); + } + + let quote; + + if (actualStatus.quote) { + if (actualStatus.pleroma.get('quote_visible', true) === false) { + quote = ( +
+

+
+ ); + } else { + quote = ; + } + } + + const handlers = muted ? undefined : { + reply: handleHotkeyReply, + favourite: handleHotkeyFavourite, + boost: handleHotkeyBoost, + mention: handleHotkeyMention, + open: handleHotkeyOpen, + openProfile: handleHotkeyOpenProfile, + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + toggleHidden: handleHotkeyToggleHidden, + toggleSensitive: handleHotkeyToggleSensitive, + openMedia: handleHotkeyOpenMedia, + react: handleHotkeyReact, + }; + + const statusUrl = `/@${actualStatus.getIn(['account', 'acct'])}/posts/${actualStatus.id}`; + + return ( + +
history.push(statusUrl)} + role='link' + > + {prepend} + +
+ {reblogElementMobile} + +
+ +
+ +
+ {!group && actualStatus.group && ( +
+ Posted in {String(actualStatus.getIn(['group', 'title']))} +
+ )} + + + + + + + + {quote} + + {!hideActionBar && ( +
+ +
+ )} +
+
+
+
+ ); +}; + +export default Status; diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx deleted file mode 100644 index c3a3087a3..000000000 --- a/app/soapbox/components/status_action_bar.tsx +++ /dev/null @@ -1,738 +0,0 @@ -import { List as ImmutableList } from 'immutable'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, IntlShape } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; - -import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; -import { openModal } from 'soapbox/actions/modals'; -import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; -import StatusActionButton from 'soapbox/components/status-action-button'; -import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; -import { isUserTouching } from 'soapbox/is_mobile'; -import { getReactForStatus, reduceEmoji } from 'soapbox/utils/emoji_reacts'; -import { getFeatures } from 'soapbox/utils/features'; - -import type { History } from 'history'; -import type { AnyAction, Dispatch } from 'redux'; -import type { Menu } from 'soapbox/components/dropdown_menu'; -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' }, - redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, - edit: { id: 'status.edit', defaultMessage: 'Edit' }, - direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, - chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, - mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, - reply: { id: 'status.reply', defaultMessage: 'Reply' }, - share: { id: 'status.share', defaultMessage: 'Share' }, - more: { id: 'status.more', defaultMessage: 'More' }, - replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, - reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Like' }, - open: { id: 'status.open', defaultMessage: 'Expand this post' }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, - report: { id: 'status.report', defaultMessage: 'Report @{name}' }, - muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, - unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, - unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, - embed: { id: 'status.embed', defaultMessage: 'Embed' }, - admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, - admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, - copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' }, - group_remove_post: { id: 'status.remove_post_from_group', defaultMessage: 'Remove post from group' }, - deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, - markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, - markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, - reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, - reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, - reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, - reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, - reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, - reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, - quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, -}); - -interface IStatusActionBar extends RouteComponentProps { - status: Status, - onOpenUnauthorizedModal: (modalType?: string) => void, - onOpenReblogsModal: (acct: string, statusId: string) => void, - onReply: (status: Status) => void, - onFavourite: (status: Status) => void, - onBookmark: (status: Status) => void, - onReblog: (status: Status, e: React.MouseEvent) => void, - onQuote: (status: Status) => void, - onDelete: (status: Status, redraft?: boolean) => void, - onEdit: (status: Status) => void, - onDirect: (account: any) => void, - onChat: (account: any, history: History) => void, - onMention: (account: any) => 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: 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: React.MouseEventHandler = (e) => { - const { me, onReply, onOpenUnauthorizedModal, status } = this.props; - - if (me) { - onReply(status); - } else { - onOpenUnauthorizedModal('REPLY'); - } - - e.stopPropagation(); - } - - handleShareClick = () => { - navigator.share({ - text: this.props.status.search_index, - url: this.props.status.uri, - }).catch((e) => { - if (e.name !== 'AbortError') console.error(e); - }); - } - - handleLikeButtonHover: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: true }); - } - } - - handleLikeButtonLeave: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: false }); - } - } - - handleLikeButtonClick: React.EventHandler = (e) => { - const { features } = this.props; - - const reactForStatus = getReactForStatus(this.props.status, this.props.allowedEmoji); - const meEmojiReact = typeof reactForStatus === 'string' ? reactForStatus : '👍'; - - if (features.emojiReacts && isUserTouching()) { - if (this.state.emojiSelectorVisible) { - this.handleReact(meEmojiReact); - } else { - this.setState({ emojiSelectorVisible: true }); - } - } else { - this.handleReact(meEmojiReact); - } - - e.stopPropagation(); - } - - 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: React.EventHandler = (e) => { - const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; - if (me) { - onFavourite(status); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - - e.stopPropagation(); - } - - handleBookmarkClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onBookmark(this.props.status); - } - - handleReblogClick: React.EventHandler = e => { - const { me, onReblog, onOpenUnauthorizedModal, status } = this.props; - e.stopPropagation(); - - if (me) { - onReblog(status, e); - } else { - onOpenUnauthorizedModal('REBLOG'); - } - } - - handleQuoteClick: React.EventHandler = (e) => { - e.stopPropagation(); - const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; - if (me) { - onQuote(status); - } else { - onOpenUnauthorizedModal('REBLOG'); - } - } - - handleDeleteClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onDelete(this.props.status); - } - - handleRedraftClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onDelete(this.props.status, true); - } - - handleEditClick: React.EventHandler = () => { - this.props.onEdit(this.props.status); - } - - handlePinClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onPin(this.props.status); - } - - handleMentionClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onMention(this.props.status.account); - } - - handleDirectClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onDirect(this.props.status.account); - } - - handleChatClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onChat(this.props.status.account, this.props.history); - } - - handleMuteClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onMute(this.props.status.account); - } - - handleBlockClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onBlock(this.props.status); - } - - handleOpen: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/posts/${this.props.status.id}`); - } - - handleEmbed = () => { - this.props.onEmbed(this.props.status); - } - - handleReport: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onReport(this.props.status); - } - - handleConversationMuteClick: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onMuteConversation(this.props.status); - } - - handleCopy: React.EventHandler = (e) => { - const { url } = this.props.status; - const textarea = document.createElement('textarea'); - - e.stopPropagation(); - - textarea.textContent = url; - textarea.style.position = 'fixed'; - - document.body.appendChild(textarea); - - try { - textarea.select(); - document.execCommand('copy'); - } catch { - // Do nothing - } finally { - document.body.removeChild(textarea); - } - } - - // 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: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onDeleteUser(this.props.status); - } - - handleDeleteStatus: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onDeleteStatus(this.props.status); - } - - handleToggleStatusSensitivity: React.EventHandler = (e) => { - e.stopPropagation(); - this.props.onToggleStatusSensitivity(this.props.status); - } - - handleOpenReblogsModal = () => { - const { me, status, onOpenUnauthorizedModal, onOpenReblogsModal } = this.props; - - if (!me) onOpenUnauthorizedModal(); - else onOpenReblogsModal(String(status.getIn(['account', 'acct'])), status.id); - } - - _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: Menu = []; - - menu.push({ - text: intl.formatMessage(messages.open), - action: this.handleOpen, - icon: require('@tabler/icons/arrows-vertical.svg'), - }); - - if (publicStatus) { - menu.push({ - text: intl.formatMessage(messages.copy), - action: this.handleCopy, - icon: require('@tabler/icons/link.svg'), - }); - - if (features.embeds) { - menu.push({ - text: intl.formatMessage(messages.embed), - action: this.handleEmbed, - icon: require('@tabler/icons/share.svg'), - }); - } - } - - if (!me) { - return menu; - } - - if (features.bookmarks) { - menu.push({ - text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), - action: this.handleBookmarkClick, - icon: status.bookmarked ? require('@tabler/icons/bookmark-off.svg') : require('@tabler/icons/bookmark.svg'), - }); - } - - menu.push(null); - - if (ownAccount || withDismiss) { - menu.push({ - text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), - action: this.handleConversationMuteClick, - icon: mutingConversation ? require('@tabler/icons/bell.svg') : require('@tabler/icons/bell-off.svg'), - }); - menu.push(null); - } - - if (ownAccount) { - if (publicStatus) { - menu.push({ - text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), - action: this.handlePinClick, - icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), - }); - } else { - if (status.visibility === 'private') { - menu.push({ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }); - } - } - - menu.push({ - text: intl.formatMessage(messages.delete), - action: this.handleDeleteClick, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - if (features.editStatuses) { - menu.push({ - text: intl.formatMessage(messages.edit), - action: this.handleEditClick, - icon: require('@tabler/icons/edit.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.redraft), - action: this.handleRedraftClick, - icon: require('@tabler/icons/edit.svg'), - destructive: true, - }); - } - } else { - menu.push({ - text: intl.formatMessage(messages.mention, { name: username }), - action: this.handleMentionClick, - icon: require('@tabler/icons/at.svg'), - }); - - // if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) { - // menu.push({ - // text: intl.formatMessage(messages.chat, { name: username }), - // action: this.handleChatClick, - // icon: require('@tabler/icons/messages.svg'), - // }); - // } else { - // menu.push({ - // text: intl.formatMessage(messages.direct, { name: username }), - // action: this.handleDirectClick, - // icon: require('@tabler/icons/mail.svg'), - // }); - // } - - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.mute, { name: username }), - action: this.handleMuteClick, - icon: require('@tabler/icons/circle-x.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.block, { name: username }), - action: this.handleBlockClick, - icon: require('@tabler/icons/ban.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.report, { name: username }), - action: this.handleReport, - icon: require('@tabler/icons/flag.svg'), - }); - } - - if (isStaff) { - menu.push(null); - - if (isAdmin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: username }), - href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, - icon: require('@tabler/icons/gavel.svg'), - action: (event) => event.stopPropagation(), - }); - menu.push({ - text: intl.formatMessage(messages.admin_status), - href: `/pleroma/admin/#/statuses/${status.id}/`, - icon: require('@tabler/icons/pencil.svg'), - action: (event) => event.stopPropagation(), - }); - } - - menu.push({ - text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), - action: this.handleToggleStatusSensitivity, - icon: require('@tabler/icons/alert-triangle.svg'), - }); - - if (!ownAccount) { - menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: username }), - action: this.handleDeactivateUser, - icon: require('@tabler/icons/user-off.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: username }), - action: this.handleDeleteUser, - icon: require('@tabler/icons/user-minus.svg'), - destructive: true, - }); - menu.push({ - text: intl.formatMessage(messages.deleteStatus), - action: this.handleDeleteStatus, - icon: require('@tabler/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/user-x.svg'), - // destructive: true, - // }); - // menu.push({ - // text: intl.formatMessage(messages.group_remove_post), - // action: this.handleGroupRemovePost, - // icon: require('@tabler/icons/trash.svg'), - // destructive: true, - // }); - // } - - return menu; - } - - setRef = (c: HTMLDivElement) => { - this.node = c; - } - - componentDidMount() { - 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 publicStatus = ['public', 'unlisted'].includes(status.visibility); - - const replyCount = status.replies_count; - const reblogCount = status.reblogs_count; - const favouriteCount = status.favourites_count; - - const emojiReactCount = reduceEmoji( - (status.pleroma.get('emoji_reactions') || ImmutableList()) as ImmutableList, - favouriteCount, - status.favourited, - allowedEmoji, - ).reduce((acc, cur) => acc + cur.get('count'), 0); - - const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; - - const reactMessages = { - '👍': messages.reactionLike, - '❤️': messages.reactionHeart, - '😆': messages.reactionLaughing, - '😮': messages.reactionOpenMouth, - '😢': messages.reactionCry, - '😩': messages.reactionWeary, - '': messages.favourite, - }; - - const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); - - const menu = this._makeMenu(publicStatus); - let reblogIcon = require('@tabler/icons/repeat.svg'); - let replyTitle; - - if (status.visibility === 'direct') { - reblogIcon = require('@tabler/icons/mail.svg'); - } else if (status.visibility === 'private') { - reblogIcon = require('@tabler/icons/lock.svg'); - } - - const reblogMenu = [{ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }, { - text: intl.formatMessage(messages.quotePost), - action: this.handleQuoteClick, - icon: require('@tabler/icons/quote.svg'), - }]; - - const reblogButton = ( - - ); - - if (!status.in_reply_to_id) { - replyTitle = intl.formatMessage(messages.reply); - } else { - replyTitle = intl.formatMessage(messages.replyAll); - } - - const canShare = ('share' in navigator) && status.visibility === 'public'; - - return ( -
- - - {(features.quotePosts && me) ? ( - - {reblogButton} - - ) : ( - reblogButton - )} - - {features.emojiReacts ? ( - - - - ) : ( - - )} - - {canShare && ( - - )} - - - - -
- ); - } - -} - -const mapStateToProps = (state: RootState) => { - const { me, instance } = state; - const account = state.accounts.get(me); - - return { - me, - isStaff: account ? account.staff : false, - isAdmin: account ? account.admin : false, - features: getFeatures(instance), - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch, { status }: { status: Status}) => ({ - dispatch, - onOpenUnauthorizedModal(action: AnyAction) { - dispatch(openModal('UNAUTHORIZED', { - action, - ap_id: status.url, - })); - }, - onOpenReblogsModal(username: string, statusId: string) { - dispatch(openModal('REBLOGS', { - username, - statusId, - })); - }, -}); - -const WrappedComponent = withRouter(injectIntl(StatusActionBar)); -// @ts-ignore -export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(WrappedComponent); diff --git a/app/soapbox/containers/status_container.js b/app/soapbox/containers/status_container.js deleted file mode 100644 index 987774aa7..000000000 --- a/app/soapbox/containers/status_container.js +++ /dev/null @@ -1,270 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { launchChat } from 'soapbox/actions/chats'; -import { deactivateUserModal, deleteUserModal, deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; - -import { blockAccount } from '../actions/accounts'; -import { showAlertForError } from '../actions/alerts'; -import { - replyCompose, - mentionCompose, - directCompose, - quoteCompose, -} from '../actions/compose'; -import { - createRemovedAccount, - groupRemoveStatus, -} from '../actions/groups'; -import { - reblog, - favourite, - unreblog, - unfavourite, - bookmark, - unbookmark, - pin, - unpin, -} from '../actions/interactions'; -import { openModal } from '../actions/modals'; -import { initMuteModal } from '../actions/mutes'; -import { initReport } from '../actions/reports'; -import { getSettings } from '../actions/settings'; -import { - muteStatus, - unmuteStatus, - deleteStatus, - hideStatus, - revealStatus, - editStatus, -} from '../actions/statuses'; -import Status from '../components/status'; -import { makeGetStatus } from '../selectors'; - -const messages = defineMessages({ - deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, - deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, - deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, - redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, -}); - -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const mapStateToProps = (state, props) => { - const soapbox = getSoapboxConfig(state); - - return { - status: getStatus(state, props), - displayMedia: getSettings(state).get('displayMedia'), - allowedEmoji: soapbox.get('allowedEmoji'), - }; - }; - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => { - function onModalReblog(status) { - if (status.get('reblogged')) { - dispatch(unreblog(status)); - } else { - dispatch(reblog(status)); - } - } - - return { - onReply(status) { - dispatch((_, getState) => { - const state = getState(); - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }); - }, - - onModalReblog, - - onReblog(status, e) { - dispatch((_, getState) => { - const boostModal = getSettings(getState()).get('boostModal'); - if ((e && e.shiftKey) || !boostModal) { - onModalReblog(status); - } else { - dispatch(openModal('BOOST', { status, onReblog: onModalReblog })); - } - }); - }, - - onQuote(status) { - dispatch((_, getState) => { - const state = getState(); - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status)), - })); - } else { - dispatch(quoteCompose(status)); - } - }); - }, - - onFavourite(status) { - if (status.get('favourited')) { - dispatch(unfavourite(status)); - } else { - dispatch(favourite(status)); - } - }, - - onBookmark(status) { - if (status.get('bookmarked')) { - dispatch(unbookmark(status)); - } else { - dispatch(bookmark(status)); - } - }, - - onPin(status) { - if (status.get('pinned')) { - dispatch(unpin(status)); - } else { - dispatch(pin(status)); - } - }, - - onEmbed(status) { - dispatch(openModal('EMBED', { - url: status.get('url'), - onError: error => dispatch(showAlertForError(error)), - })); - }, - - onDelete(status, withRedraft = false) { - dispatch((_, getState) => { - const deleteModal = getSettings(getState()).get('deleteModal'); - if (!deleteModal) { - dispatch(deleteStatus(status.get('id'), withRedraft)); - } else { - dispatch(openModal('CONFIRM', { - icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), - heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), - message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), - confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)), - })); - } - }); - }, - - onEdit(status) { - dispatch(editStatus(status.get('id'))); - }, - - onDirect(account) { - dispatch(directCompose(account)); - }, - - onChat(account, router) { - dispatch(launchChat(account.get('id'), router)); - }, - - onMention(account) { - dispatch(mentionCompose(account)); - }, - - onOpenMedia(media, index) { - dispatch(openModal('MEDIA', { media, index })); - }, - - onOpenVideo(media, time) { - dispatch(openModal('VIDEO', { media, time })); - }, - - onOpenAudio(media, time) { - dispatch(openModal('AUDIO', { media, time })); - }, - - onBlock(status) { - const account = status.get('account'); - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.get('acct')}
}} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); - }, - - onReport(status) { - dispatch(initReport(status.get('account'), status)); - }, - - onMute(account) { - dispatch(initMuteModal(account)); - }, - - onMuteConversation(status) { - if (status.get('muted')) { - dispatch(unmuteStatus(status.get('id'))); - } else { - dispatch(muteStatus(status.get('id'))); - } - }, - - onToggleHidden(status) { - if (status.get('hidden')) { - dispatch(revealStatus(status.get('id'))); - } else { - dispatch(hideStatus(status.get('id'))); - } - }, - - onGroupRemoveAccount(groupId, accountId) { - dispatch(createRemovedAccount(groupId, accountId)); - }, - - onGroupRemoveStatus(groupId, statusId) { - dispatch(groupRemoveStatus(groupId, statusId)); - }, - - onDeactivateUser(status) { - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']))); - }, - - onDeleteUser(status) { - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']))); - }, - - onDeleteStatus(status) { - dispatch(deleteStatusModal(intl, status.get('id'))); - }, - - onToggleStatusSensitivity(status) { - dispatch(toggleStatusSensitivityModal(intl, status.get('id'), status.get('sensitive'))); - }, - }; -}; - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/soapbox/containers/status_container.tsx b/app/soapbox/containers/status_container.tsx new file mode 100644 index 000000000..e5ac5014d --- /dev/null +++ b/app/soapbox/containers/status_container.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import Status, { IStatus } from 'soapbox/components/status'; +import { useAppSelector } from 'soapbox/hooks'; +import { makeGetStatus } from 'soapbox/selectors'; + +interface IStatusContainer extends Omit { + id: string, + /** @deprecated Unused. */ + contextType?: any, + /** @deprecated Unused. */ + otherAccounts?: any, + /** @deprecated Unused. */ + withDismiss?: any, + /** @deprecated Unused. */ + getScrollPosition?: any, + /** @deprecated Unused. */ + updateScrollBottom?: any, +} + +const getStatus = makeGetStatus(); + +/** + * Legacy Status wrapper accepting a status ID instead of the full entity. + * @deprecated Use the Status component directly. + */ +const StatusContainer: React.FC = ({ id }) => { + const status = useAppSelector(state => getStatus(state, { id })); + + if (status) { + return ; + } else { + return null; + } +}; + +export default StatusContainer; diff --git a/app/soapbox/features/compose/components/reply_indicator.tsx b/app/soapbox/features/compose/components/reply_indicator.tsx index 5a6a5f706..3af9c49a8 100644 --- a/app/soapbox/features/compose/components/reply_indicator.tsx +++ b/app/soapbox/features/compose/components/reply_indicator.tsx @@ -43,7 +43,7 @@ const ReplyIndicator: React.FC = ({ status, hideActions, onCanc /> void }) => { - const dispatch = useDispatch(); + const { data, fetchNextPage, hasNextPage, isFetching } = useOnboardingSuggestions(); - const suggestions = useAppSelector((state) => state.suggestions.items); - const hasMore = useAppSelector((state) => !!state.suggestions.next); - const isLoading = useAppSelector((state) => state.suggestions.isLoading); const handleLoadMore = debounce(() => { - if (isLoading) { + if (isFetching) { return null; } - return dispatch(fetchSuggestions()); + return fetchNextPage(); }, 300); - React.useEffect(() => { - dispatch(fetchSuggestions({ limit: 20 })); - }, []); - const renderSuggestions = () => { + if (!data) { + return null; + } + return (
- {suggestions.map((suggestion) => ( -
+ {data.map((suggestion) => ( +
, but it isn't - id={suggestion.account} + id={suggestion.account.id} showProfileHoverCard={false} withLinkToProfile={false} /> @@ -65,7 +59,7 @@ const SuggestedAccountsStep = ({ onNext }: { onNext: () => void }) => { }; const renderBody = () => { - if (suggestions.isEmpty()) { + if (!data || data.length === 0) { return renderEmpty(); } else { return renderSuggestions(); diff --git a/app/soapbox/features/status/components/action-bar.tsx b/app/soapbox/features/status/components/action-bar.tsx deleted file mode 100644 index b0560693c..000000000 --- a/app/soapbox/features/status/components/action-bar.tsx +++ /dev/null @@ -1,654 +0,0 @@ -import classNames from 'classnames'; -import React from 'react'; -import { defineMessages, injectIntl, WrappedComponentProps as IntlComponentProps } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; - -import { openModal } from 'soapbox/actions/modals'; -import EmojiButtonWrapper from 'soapbox/components/emoji-button-wrapper'; -import { HStack, IconButton, Emoji, Text } from 'soapbox/components/ui'; -import DropdownMenuContainer from 'soapbox/containers/dropdown_menu_container'; -import { isUserTouching } from 'soapbox/is_mobile'; -import { getReactForStatus } from 'soapbox/utils/emoji_reacts'; -import { getFeatures } from 'soapbox/utils/features'; - -import type { History } from 'history'; -import type { List as ImmutableList } from 'immutable'; -import type { AnyAction } from 'redux'; -import type { ThunkDispatch } from 'redux-thunk'; -import type { Menu } from 'soapbox/components/dropdown_menu'; -import type { RootState } from 'soapbox/store'; -import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; - -type Dispatch = ThunkDispatch; - -const messages = defineMessages({ - delete: { id: 'status.delete', defaultMessage: 'Delete' }, - redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, - edit: { id: 'status.edit', defaultMessage: 'Edit' }, - direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, - chat: { id: 'status.chat', defaultMessage: 'Chat with @{name}' }, - mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, - reply: { id: 'status.reply', defaultMessage: 'Reply' }, - reblog: { id: 'status.reblog', defaultMessage: 'Repost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Repost to original audience' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Un-repost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be reposted' }, - favourite: { id: 'status.favourite', defaultMessage: 'Like' }, - mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' }, - muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, - unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, - block: { id: 'status.block', defaultMessage: 'Block @{name}' }, - report: { id: 'status.report', defaultMessage: 'Report @{name}' }, - share: { id: 'status.share', defaultMessage: 'Share' }, - pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, - unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, - embed: { id: 'status.embed', defaultMessage: 'Embed' }, - admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, - admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' }, - copy: { id: 'status.copy', defaultMessage: 'Copy link to post' }, - bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, - unbookmark: { id: 'status.unbookmark', defaultMessage: 'Remove bookmark' }, - deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' }, - markStatusSensitive: { id: 'admin.statuses.actions.mark_status_sensitive', defaultMessage: 'Mark post sensitive' }, - markStatusNotSensitive: { id: 'admin.statuses.actions.mark_status_not_sensitive', defaultMessage: 'Mark post not sensitive' }, - reactionLike: { id: 'status.reactions.like', defaultMessage: 'Like' }, - reactionHeart: { id: 'status.reactions.heart', defaultMessage: 'Love' }, - reactionLaughing: { id: 'status.reactions.laughing', defaultMessage: 'Haha' }, - reactionOpenMouth: { id: 'status.reactions.open_mouth', defaultMessage: 'Wow' }, - reactionCry: { id: 'status.reactions.cry', defaultMessage: 'Sad' }, - reactionWeary: { id: 'status.reactions.weary', defaultMessage: 'Weary' }, - emojiPickerExpand: { id: 'status.reactions_expand', defaultMessage: 'Select emoji' }, - more: { id: 'status.actions.more', defaultMessage: 'More' }, - quotePost: { id: 'status.quote', defaultMessage: 'Quote post' }, -}); - -const mapStateToProps = (state: RootState) => { - const me = state.me; - const account = state.accounts.get(me); - const instance = state.instance; - - return { - me, - isStaff: account ? account.staff : false, - isAdmin: account ? account.admin : false, - features: getFeatures(instance), - }; -}; - -const mapDispatchToProps = (dispatch: Dispatch, { status }: OwnProps) => ({ - onOpenUnauthorizedModal(action: string) { - dispatch(openModal('UNAUTHORIZED', { - action, - ap_id: status.url, - })); - }, -}); - -interface OwnProps { - status: StatusEntity, - onReply: (status: StatusEntity) => void, - onReblog: (status: StatusEntity, e: React.MouseEvent) => void, - onQuote: (status: StatusEntity) => void, - onFavourite: (status: StatusEntity) => void, - onEmojiReact: (status: StatusEntity, emoji: string) => void, - onDelete: (status: StatusEntity, redraft?: boolean) => void, - onEdit: (status: StatusEntity) => void, - onBookmark: (status: StatusEntity) => void, - onDirect: (account: AccountEntity) => void, - onChat: (account: AccountEntity, history: History) => void, - onMention: (account: AccountEntity) => void, - onMute: (account: AccountEntity) => void, - onMuteConversation: (status: StatusEntity) => void, - onBlock: (status: StatusEntity) => void, - onReport: (status: StatusEntity) => void, - onPin: (status: StatusEntity) => void, - onEmbed: (status: StatusEntity) => void, - onDeactivateUser: (status: StatusEntity) => void, - onDeleteUser: (status: StatusEntity) => void, - onDeleteStatus: (status: StatusEntity) => void, - onToggleStatusSensitivity: (status: StatusEntity) => void, - allowedEmoji: ImmutableList, - emojiSelectorFocused: boolean, - handleEmojiSelectorExpand: React.EventHandler, - handleEmojiSelectorUnfocus: React.EventHandler, -} - -type StateProps = ReturnType; -type DispatchProps = ReturnType; - -type IActionBar = OwnProps & StateProps & DispatchProps & RouteComponentProps & IntlComponentProps; - -interface IActionBarState { - emojiSelectorVisible: boolean, - emojiSelectorFocused: boolean, -} - -class ActionBar extends React.PureComponent { - - static defaultProps: Partial = { - isStaff: false, - } - - state = { - emojiSelectorVisible: false, - emojiSelectorFocused: false, - } - - node: HTMLDivElement | null = null; - - handleReplyClick: React.EventHandler = (e) => { - const { me, onReply, onOpenUnauthorizedModal } = this.props; - e.preventDefault(); - - if (me) { - onReply(this.props.status); - } else { - onOpenUnauthorizedModal('REPLY'); - } - } - - handleReblogClick: React.EventHandler = (e) => { - const { me, onReblog, onOpenUnauthorizedModal, status } = this.props; - e.preventDefault(); - - if (me) { - onReblog(status, e); - } else { - onOpenUnauthorizedModal('REBLOG'); - } - } - - handleQuoteClick: React.EventHandler = () => { - const { me, onQuote, onOpenUnauthorizedModal, status } = this.props; - if (me) { - onQuote(status); - } else { - onOpenUnauthorizedModal('REBLOG'); - } - } - - handleBookmarkClick: React.EventHandler = () => { - this.props.onBookmark(this.props.status); - } - - handleFavouriteClick: React.EventHandler = (e) => { - const { me, onFavourite, onOpenUnauthorizedModal, status } = this.props; - - e.preventDefault(); - - if (me) { - onFavourite(status); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - } - - handleLikeButtonHover: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: true }); - } - } - - handleLikeButtonLeave: React.EventHandler = () => { - const { features } = this.props; - - if (features.emojiReacts && !isUserTouching()) { - this.setState({ emojiSelectorVisible: false }); - } - } - - handleLikeButtonClick: React.EventHandler = e => { - const { features } = this.props; - const meEmojiReact = getReactForStatus(this.props.status, this.props.allowedEmoji) || '👍'; - - if (features.emojiReacts && isUserTouching()) { - if (this.state.emojiSelectorVisible) { - this.handleReactClick(meEmojiReact)(e); - } else { - this.setState({ emojiSelectorVisible: true }); - } - } else { - this.handleReactClick(meEmojiReact)(e); - } - } - - handleReactClick = (emoji: string): React.EventHandler => { - return () => { - const { me, onEmojiReact, onOpenUnauthorizedModal, status } = this.props; - if (me) { - onEmojiReact(status, emoji); - } else { - onOpenUnauthorizedModal('FAVOURITE'); - } - this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); - }; - } - - handleHotkeyEmoji = () => { - const { emojiSelectorVisible } = this.state; - - this.setState({ emojiSelectorVisible: !emojiSelectorVisible }); - } - - handleDeleteClick: React.EventHandler = () => { - this.props.onDelete(this.props.status); - } - - handleRedraftClick: React.EventHandler = () => { - this.props.onDelete(this.props.status, true); - } - - handleEditClick: React.EventHandler = () => { - this.props.onEdit(this.props.status); - } - - handleDirectClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onDirect(account); - } - - handleChatClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onChat(account, this.props.history); - } - - handleMentionClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onMention(account); - } - - handleMuteClick: React.EventHandler = () => { - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.props.onMute(account); - } - - handleConversationMuteClick: React.EventHandler = () => { - this.props.onMuteConversation(this.props.status); - } - - handleBlockClick: React.EventHandler = () => { - this.props.onBlock(this.props.status); - } - - handleReport = () => { - this.props.onReport(this.props.status); - } - - handlePinClick: React.EventHandler = () => { - this.props.onPin(this.props.status); - } - - handleShare = () => { - navigator.share({ - text: this.props.status.search_index, - url: this.props.status.uri, - }); - } - - handleEmbed = () => { - this.props.onEmbed(this.props.status); - } - - handleCopy = () => { - const url = this.props.status.url; - const textarea = document.createElement('textarea'); - - textarea.textContent = url; - textarea.style.position = 'fixed'; - - document.body.appendChild(textarea); - - try { - textarea.select(); - document.execCommand('copy'); - } catch (e) { - // Do nothing - } finally { - document.body.removeChild(textarea); - } - } - - handleDeactivateUser = () => { - this.props.onDeactivateUser(this.props.status); - } - - handleDeleteUser = () => { - this.props.onDeleteUser(this.props.status); - } - - handleToggleStatusSensitivity = () => { - this.props.onToggleStatusSensitivity(this.props.status); - } - - handleDeleteStatus = () => { - this.props.onDeleteStatus(this.props.status); - } - - setRef: React.RefCallback = c => { - this.node = c; - } - - componentDidMount() { - document.addEventListener('click', e => { - if (this.node && !this.node.contains(e.target as Element)) - this.setState({ emojiSelectorVisible: false, emojiSelectorFocused: false }); - }); - } - - render() { - const { status, intl, me, isStaff, isAdmin, allowedEmoji, features } = this.props; - const ownAccount = status.getIn(['account', 'id']) === me; - const username = String(status.getIn(['account', 'acct'])); - - const publicStatus = ['public', 'unlisted'].includes(status.visibility); - const mutingConversation = status.muted; - - const meEmojiReact = getReactForStatus(status, allowedEmoji) as keyof typeof reactMessages | undefined; - - const reactMessages = { - '👍': messages.reactionLike, - '❤️': messages.reactionHeart, - '😆': messages.reactionLaughing, - '😮': messages.reactionOpenMouth, - '😢': messages.reactionCry, - '😩': messages.reactionWeary, - '': messages.favourite, - }; - - const meEmojiTitle = intl.formatMessage(reactMessages[meEmojiReact || ''] || messages.favourite); - - const menu: Menu = []; - - if (publicStatus) { - menu.push({ - text: intl.formatMessage(messages.copy), - action: this.handleCopy, - icon: require('@tabler/icons/link.svg'), - }); - - if (features.embeds) { - menu.push({ - text: intl.formatMessage(messages.embed), - action: this.handleEmbed, - icon: require('@tabler/icons/share.svg'), - }); - } - } - - if (me) { - if (features.bookmarks) { - menu.push({ - text: intl.formatMessage(status.bookmarked ? messages.unbookmark : messages.bookmark), - action: this.handleBookmarkClick, - icon: status.bookmarked ? require('@tabler/icons/bookmark-off.svg') : require('@tabler/icons/bookmark.svg'), - }); - } - - menu.push(null); - - if (ownAccount) { - if (publicStatus) { - menu.push({ - text: intl.formatMessage(status.pinned ? messages.unpin : messages.pin), - action: this.handlePinClick, - icon: mutingConversation ? require('@tabler/icons/pinned-off.svg') : require('@tabler/icons/pin.svg'), - }); - - menu.push(null); - } else if (status.visibility === 'private') { - menu.push({ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }); - - menu.push(null); - } - - menu.push({ - text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), - action: this.handleConversationMuteClick, - icon: mutingConversation ? require('@tabler/icons/bell.svg') : require('@tabler/icons/bell-off.svg'), - }); - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.delete), - action: this.handleDeleteClick, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - if (features.editStatuses) { - menu.push({ - text: intl.formatMessage(messages.edit), - action: this.handleEditClick, - icon: require('@tabler/icons/edit.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.redraft), - action: this.handleRedraftClick, - icon: require('@tabler/icons/edit.svg'), - destructive: true, - }); - } - } else { - menu.push({ - text: intl.formatMessage(messages.mention, { name: username }), - action: this.handleMentionClick, - icon: require('@tabler/icons/at.svg'), - }); - - // if (status.getIn(['account', 'pleroma', 'accepts_chat_messages'], false) === true) { - // menu.push({ - // text: intl.formatMessage(messages.chat, { name: username }), - // action: this.handleChatClick, - // icon: require('@tabler/icons/messages.svg'), - // }); - // } else { - // menu.push({ - // text: intl.formatMessage(messages.direct, { name: username }), - // action: this.handleDirectClick, - // icon: require('@tabler/icons/mail.svg'), - // }); - // } - - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.mute, { name: username }), - action: this.handleMuteClick, - icon: require('@tabler/icons/circle-x.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.block, { name: username }), - action: this.handleBlockClick, - icon: require('@tabler/icons/ban.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.report, { name: username }), - action: this.handleReport, - icon: require('@tabler/icons/flag.svg'), - }); - } - - if (isStaff) { - menu.push(null); - - if (isAdmin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: username }), - href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`, - icon: require('@tabler/icons/gavel.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.admin_status), - href: `/pleroma/admin/#/statuses/${status.id}/`, - icon: require('@tabler/icons/pencil.svg'), - }); - } - - menu.push({ - text: intl.formatMessage(status.sensitive === false ? messages.markStatusSensitive : messages.markStatusNotSensitive), - action: this.handleToggleStatusSensitivity, - icon: require('@tabler/icons/alert-triangle.svg'), - }); - - if (!ownAccount) { - menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: username }), - action: this.handleDeactivateUser, - icon: require('@tabler/icons/user-off.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: username }), - action: this.handleDeleteUser, - icon: require('@tabler/icons/user-minus.svg'), - destructive: true, - }); - menu.push({ - text: intl.formatMessage(messages.deleteStatus), - action: this.handleDeleteStatus, - icon: require('@tabler/icons/trash.svg'), - destructive: true, - }); - } - } - } - - const canShare = ('share' in navigator) && status.visibility === 'public'; - - let reblogIcon = require('@tabler/icons/repeat.svg'); - - if (status.visibility === 'direct') { - reblogIcon = require('@tabler/icons/mail.svg'); - } else if (status.visibility === 'private') { - reblogIcon = require('@tabler/icons/lock.svg'); - } - - const reblog_disabled = (status.visibility === 'direct' || status.visibility === 'private'); - - const reblogMenu: Menu = [{ - text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog), - action: this.handleReblogClick, - icon: require('@tabler/icons/repeat.svg'), - }, { - text: intl.formatMessage(messages.quotePost), - action: this.handleQuoteClick, - icon: require('@tabler/icons/quote.svg'), - }]; - - const reblogButton = ( - - ); - - return ( - - - - {(features.quotePosts && me) ? ( - - {reblogButton} - - ) : ( - reblogButton - )} - - {features.emojiReacts ? ( - - {meEmojiReact ? ( - - ) : ( - - )} - - ) : ( - - )} - - {canShare && ( - - )} - - - - ); - } - -} - -const WrappedComponent = withRouter(injectIntl(ActionBar)); -export default connect(mapStateToProps, mapDispatchToProps)(WrappedComponent); diff --git a/app/soapbox/features/status/components/detailed-status.tsx b/app/soapbox/features/status/components/detailed-status.tsx index ebdb0be46..250f81257 100644 --- a/app/soapbox/features/status/components/detailed-status.tsx +++ b/app/soapbox/features/status/components/detailed-status.tsx @@ -1,7 +1,5 @@ -import classNames from 'classnames'; -import React from 'react'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedDate, FormattedMessage, injectIntl, WrappedComponentProps as IntlProps } from 'react-intl'; +import React, { useRef } from 'react'; +import { FormattedDate, FormattedMessage, useIntl } from 'react-intl'; import Icon from 'soapbox/components/icon'; import StatusMedia from 'soapbox/components/status-media'; @@ -10,192 +8,131 @@ import StatusContent from 'soapbox/components/status_content'; import { HStack, Text } from 'soapbox/components/ui'; import AccountContainer from 'soapbox/containers/account_container'; import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container'; -import scheduleIdleTask from 'soapbox/features/ui/util/schedule_idle_task'; +import { getActualStatus } from 'soapbox/utils/status'; import StatusInteractionBar from './status-interaction-bar'; import type { List as ImmutableList } from 'immutable'; import type { Attachment as AttachmentEntity, Status as StatusEntity } from 'soapbox/types/entities'; -interface IDetailedStatus extends IntlProps { +interface IDetailedStatus { status: StatusEntity, onOpenMedia: (media: ImmutableList, index: number) => void, onOpenVideo: (media: ImmutableList, start: number) => void, onToggleHidden: (status: StatusEntity) => void, - measureHeight: boolean, - onHeightChange: () => void, - domain: string, - compact: boolean, showMedia: boolean, onOpenCompareHistoryModal: (status: StatusEntity) => void, onToggleMediaVisibility: () => void, } -interface IDetailedStatusState { - height: number | null, -} +const DetailedStatus: React.FC = ({ + status, + onToggleHidden, + onOpenCompareHistoryModal, + onToggleMediaVisibility, + showMedia, +}) => { + const intl = useIntl(); + const node = useRef(null); -class DetailedStatus extends ImmutablePureComponent { - - state = { - height: null, + const handleExpandedToggle = () => { + onToggleHidden(status); }; - node: HTMLDivElement | null = null; + const handleOpenCompareHistoryModal = () => { + onOpenCompareHistoryModal(status); + }; - handleExpandedToggle = () => { - this.props.onToggleHidden(this.props.status); - } + const actualStatus = getActualStatus(status); + if (!actualStatus) return null; + const { account } = actualStatus; + if (!account || typeof account !== 'object') return null; - handleOpenCompareHistoryModal = () => { - this.props.onOpenCompareHistoryModal(this.props.status); - } + let statusTypeIcon = null; - _measureHeight(heightJustChanged = false) { - if (this.props.measureHeight && this.node) { - scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 })); + let quote; - if (this.props.onHeightChange && heightJustChanged) { - this.props.onHeightChange(); - } - } - } - - setRef: React.RefCallback = c => { - this.node = c; - this._measureHeight(); - } - - componentDidUpdate(prevProps: IDetailedStatus, prevState: IDetailedStatusState) { - this._measureHeight(prevState.height !== this.state.height); - } - - // handleModalLink = e => { - // e.preventDefault(); - // - // let href; - // - // if (e.target.nodeName !== 'A') { - // href = e.target.parentNode.href; - // } else { - // href = e.target.href; - // } - // - // window.open(href, 'soapbox-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes'); - // } - - getActualStatus = () => { - const { status } = this.props; - if (!status) return undefined; - return status.reblog && typeof status.reblog === 'object' ? status.reblog : status; - } - - render() { - const status = this.getActualStatus(); - if (!status) return null; - const { account } = status; - if (!account || typeof account !== 'object') return null; - - const outerStyle: React.CSSProperties = { boxSizing: 'border-box' }; - const { compact } = this.props; - - let statusTypeIcon = null; - - if (this.props.measureHeight) { - outerStyle.height = `${this.state.height}px`; - } - - let quote; - - if (status.quote) { - if (status.pleroma.get('quote_visible', true) === false) { - quote = ( -
-

-
- ); - } else { - quote = ; - } - } - - if (status.visibility === 'direct') { - statusTypeIcon = ; - } else if (status.visibility === 'private') { - statusTypeIcon = ; - } - - return ( -
-
-
- -
- - {/* status.group && ( -
- Posted in {status.getIn(['group', 'title'])} -
- )*/} - - - - - - - - {quote} - - - - -
- {statusTypeIcon} - - - - - - - - - {status.edited_at && ( - <> - {' · '} -
- - - -
- - )} -
-
-
+ if (actualStatus.quote) { + if (actualStatus.pleroma.get('quote_visible', true) === false) { + quote = ( +
+

-
- ); + ); + } else { + quote = ; + } } -} + if (actualStatus.visibility === 'direct') { + statusTypeIcon = ; + } else if (actualStatus.visibility === 'private') { + statusTypeIcon = ; + } -export default injectIntl(DetailedStatus); + return ( +
+
+
+ +
+ + + + + + + + {quote} + + + + +
+ {statusTypeIcon} + + + + + + + + + {actualStatus.edited_at && ( + <> + {' · '} +
+ + + +
+ + )} +
+
+
+
+
+ ); +}; + +export default DetailedStatus; diff --git a/app/soapbox/features/status/containers/detailed_status_container.js b/app/soapbox/features/status/containers/detailed_status_container.js deleted file mode 100644 index be3b22d43..000000000 --- a/app/soapbox/features/status/containers/detailed_status_container.js +++ /dev/null @@ -1,236 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { blockAccount } from 'soapbox/actions/accounts'; -import { showAlertForError } from 'soapbox/actions/alerts'; -import { launchChat } from 'soapbox/actions/chats'; -import { - replyCompose, - mentionCompose, - directCompose, -} from 'soapbox/actions/compose'; -import { - reblog, - favourite, - unreblog, - unfavourite, - bookmark, - unbookmark, - pin, - unpin, -} from 'soapbox/actions/interactions'; -import { openModal } from 'soapbox/actions/modals'; -import { deactivateUserModal, deleteUserModal, deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation'; -import { initMuteModal } from 'soapbox/actions/mutes'; -import { initReport } from 'soapbox/actions/reports'; -import { getSettings } from 'soapbox/actions/settings'; -import { - muteStatus, - unmuteStatus, - deleteStatus, - hideStatus, - revealStatus, - editStatus, -} from 'soapbox/actions/statuses'; -import { makeGetStatus } from 'soapbox/selectors'; - -import DetailedStatus from '../components/detailed-status'; - -const messages = defineMessages({ - deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' }, - deleteHeading: { id: 'confirmations.delete.heading', defaultMessage: 'Delete post' }, - deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this post?' }, - redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' }, - redraftHeading: { id: 'confirmations.redraft.heading', defaultMessage: 'Delete & redraft' }, - redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this post and re-draft it? Favorites and reposts will be lost, and replies to the original post will be orphaned.' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, -}); - -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); - - const mapStateToProps = (state, props) => ({ - status: getStatus(state, props), - domain: state.getIn(['meta', 'domain']), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => ({ - - onReply(status) { - dispatch((_, getState) => { - const state = getState(); - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status)), - })); - } else { - dispatch(replyCompose(status)); - } - }); - }, - - onModalReblog(status) { - dispatch(reblog(status)); - }, - - onReblog(status, e) { - dispatch((_, getState) => { - const boostModal = getSettings(getState()).get('boostModal'); - if (status.get('reblogged')) { - dispatch(unreblog(status)); - } else { - if (e.shiftKey || !boostModal) { - this.onModalReblog(status); - } else { - dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); - } - } - }); - }, - - onBookmark(status) { - if (status.get('bookmarked')) { - dispatch(unbookmark(status)); - } else { - dispatch(bookmark(status)); - } - }, - - onFavourite(status) { - if (status.get('favourited')) { - dispatch(unfavourite(status)); - } else { - dispatch(favourite(status)); - } - }, - - onPin(status) { - if (status.get('pinned')) { - dispatch(unpin(status)); - } else { - dispatch(pin(status)); - } - }, - - onEmbed(status) { - dispatch(openModal('EMBED', { - url: status.get('url'), - onError: error => dispatch(showAlertForError(error)), - })); - }, - - onDelete(status, withRedraft = false) { - dispatch((_, getState) => { - const deleteModal = getSettings(getState()).get('deleteModal'); - if (!deleteModal) { - dispatch(deleteStatus(status.get('id'), withRedraft)); - } else { - dispatch(openModal('CONFIRM', { - icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), - heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), - message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), - confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.get('id'), withRedraft)), - })); - } - }); - }, - - onEdit(status) { - dispatch(editStatus(status.get('id'))); - }, - - onDirect(account) { - dispatch(directCompose(account)); - }, - - onChat(account, router) { - dispatch(launchChat(account.get('id'), router)); - }, - - onMention(account) { - dispatch(mentionCompose(account)); - }, - - onOpenMedia(media, index) { - dispatch(openModal('MEDIA', { media, index })); - }, - - onOpenVideo(media, time) { - dispatch(openModal('VIDEO', { media, time })); - }, - - onBlock(status) { - const account = status.get('account'); - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.get('acct')}
}} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.get('id'))), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.get('id'))); - dispatch(initReport(account, status)); - }, - })); - }, - - onReport(status) { - dispatch(initReport(status.get('account'), status)); - }, - - onMute(account) { - dispatch(initMuteModal(account)); - }, - - onMuteConversation(status) { - if (status.get('muted')) { - dispatch(unmuteStatus(status.get('id'))); - } else { - dispatch(muteStatus(status.get('id'))); - } - }, - - onToggleHidden(status) { - if (status.get('hidden')) { - dispatch(revealStatus(status.get('id'))); - } else { - dispatch(hideStatus(status.get('id'))); - } - }, - - onDeactivateUser(status) { - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']))); - }, - - onDeleteUser(status) { - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']))); - }, - - onToggleStatusSensitivity(status) { - dispatch(toggleStatusSensitivityModal(intl, status.get('id'), status.get('sensitive'))); - }, - - onDeleteStatus(status) { - dispatch(deleteStatusModal(intl, status.get('id'))); - }, - - onOpenCompareHistoryModal(status) { - dispatch(openModal('COMPARE_HISTORY', { - statusId: status.get('id'), - })); - }, - -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus)); diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index 505a0dd24..baf6b61ae 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -1,84 +1,54 @@ import classNames from 'classnames'; import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable'; -import React from 'react'; +import { debounce } from 'lodash'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { HotKeys } from 'react-hotkeys'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage, WrappedComponentProps as IntlComponentProps } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { defineMessages, useIntl } from 'react-intl'; +import { useHistory } from 'react-router-dom'; import { createSelector } from 'reselect'; -import { blockAccount } from 'soapbox/actions/accounts'; -import { launchChat } from 'soapbox/actions/chats'; import { replyCompose, mentionCompose, - directCompose, - quoteCompose, } from 'soapbox/actions/compose'; -import { simpleEmojiReact } from 'soapbox/actions/emoji_reacts'; import { favourite, unfavourite, reblog, unreblog, - bookmark, - unbookmark, - pin, - unpin, } from 'soapbox/actions/interactions'; import { openModal } from 'soapbox/actions/modals'; -import { - deactivateUserModal, - deleteUserModal, - deleteStatusModal, - toggleStatusSensitivityModal, -} from 'soapbox/actions/moderation'; -import { initMuteModal } from 'soapbox/actions/mutes'; -import { initReport } from 'soapbox/actions/reports'; import { getSettings } from 'soapbox/actions/settings'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { - muteStatus, - unmuteStatus, - deleteStatus, hideStatus, revealStatus, - editStatus, fetchStatusWithContext, fetchNext, } from 'soapbox/actions/statuses'; import MissingIndicator from 'soapbox/components/missing_indicator'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import ScrollableList from 'soapbox/components/scrollable_list'; -import { textForScreenReader } from 'soapbox/components/status'; +import StatusActionBar from 'soapbox/components/status-action-bar'; import SubNavigation from 'soapbox/components/sub_navigation'; import Tombstone from 'soapbox/components/tombstone'; import { Column, Stack } from 'soapbox/components/ui'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; +import { useAppDispatch, useAppSelector, useSettings } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; -import { defaultMediaVisibility } from 'soapbox/utils/status'; +import { defaultMediaVisibility, textForScreenReader } from 'soapbox/utils/status'; -import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../ui/util/fullscreen'; - -import ActionBar from './components/action-bar'; import DetailedStatus from './components/detailed-status'; import ThreadLoginCta from './components/thread-login-cta'; import ThreadStatus from './components/thread-status'; -import type { AxiosError } from 'axios'; -import type { History } from 'history'; import type { VirtuosoHandle } from 'react-virtuoso'; -import type { AnyAction } from 'redux'; -import type { ThunkDispatch } from 'redux-thunk'; import type { RootState } from 'soapbox/store'; import type { Account as AccountEntity, Attachment as AttachmentEntity, Status as StatusEntity, } from 'soapbox/types/entities'; -import type { Me } from 'soapbox/types/soapbox'; const messages = defineMessages({ title: { id: 'status.title', defaultMessage: '@{username}\'s Post' }, @@ -98,59 +68,78 @@ const messages = defineMessages({ blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); -const makeMapStateToProps = () => { - const getStatus = makeGetStatus(); +const getStatus = makeGetStatus(); - const getAncestorsIds = createSelector([ - (_: RootState, statusId: string | undefined) => statusId, - (state: RootState) => state.contexts.inReplyTos, - ], (statusId, inReplyTos) => { +const getAncestorsIds = createSelector([ + (_: RootState, statusId: string | undefined) => statusId, + (state: RootState) => state.contexts.inReplyTos, +], (statusId, inReplyTos) => { + let ancestorsIds = ImmutableOrderedSet(); + let id: string | undefined = statusId; + + while (id && !ancestorsIds.includes(id)) { + ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); + id = inReplyTos.get(id); + } + + return ancestorsIds; +}); + +const getDescendantsIds = createSelector([ + (_: RootState, statusId: string) => statusId, + (state: RootState) => state.contexts.replies, +], (statusId, contextReplies) => { + let descendantsIds = ImmutableOrderedSet(); + const ids = [statusId]; + + while (ids.length > 0) { + const id = ids.shift(); + if (!id) break; + + const replies = contextReplies.get(id); + + if (descendantsIds.includes(id)) { + break; + } + + if (statusId !== id) { + descendantsIds = descendantsIds.union([id]); + } + + if (replies) { + replies.reverse().forEach((reply: string) => { + ids.unshift(reply); + }); + } + } + + return descendantsIds; +}); + +type DisplayMedia = 'default' | 'hide_all' | 'show_all'; +type RouteParams = { statusId: string }; + +interface IThread { + params: RouteParams, + onOpenMedia: (media: ImmutableList, index: number) => void, + onOpenVideo: (video: AttachmentEntity, time: number) => void, +} + +const Thread: React.FC = (props) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const settings = useSettings(); + + const me = useAppSelector(state => state.me); + const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); + const displayMedia = settings.get('displayMedia') as DisplayMedia; + const askReplyConfirmation = useAppSelector(state => state.compose.text.trim().length !== 0); + + const { ancestorsIds, descendantsIds } = useAppSelector(state => { let ancestorsIds = ImmutableOrderedSet(); - let id: string | undefined = statusId; - - while (id && !ancestorsIds.includes(id)) { - ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); - id = inReplyTos.get(id); - } - - return ancestorsIds; - }); - - const getDescendantsIds = createSelector([ - (_: RootState, statusId: string) => statusId, - (state: RootState) => state.contexts.replies, - ], (statusId, contextReplies) => { - let descendantsIds = ImmutableOrderedSet(); - const ids = [statusId]; - - while (ids.length > 0) { - const id = ids.shift(); - if (!id) break; - - const replies = contextReplies.get(id); - - if (descendantsIds.includes(id)) { - break; - } - - if (statusId !== id) { - descendantsIds = descendantsIds.union([id]); - } - - if (replies) { - replies.reverse().forEach((reply: string) => { - ids.unshift(reply); - }); - } - } - - return descendantsIds; - }); - - const mapStateToProps = (state: RootState, props: { params: RouteParams }) => { - const status = getStatus(state, { id: props.params.statusId }); - let ancestorsIds = ImmutableOrderedSet(); - let descendantsIds = ImmutableOrderedSet(); + let descendantsIds = ImmutableOrderedSet(); if (status) { const statusId = status.id; @@ -160,116 +149,58 @@ const makeMapStateToProps = () => { descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds); } - const soapbox = getSoapboxConfig(state); - return { status, ancestorsIds, descendantsIds, - askReplyConfirmation: state.compose.text.trim().length !== 0, - me: state.me, - displayMedia: getSettings(state).get('displayMedia'), - allowedEmoji: soapbox.allowedEmoji, }; - }; + }); - return mapStateToProps; -}; + const [showMedia, setShowMedia] = useState(defaultMediaVisibility(status, displayMedia)); + const [isLoaded, setIsLoaded] = useState(!!status); + const [next, setNext] = useState(); -type DisplayMedia = 'default' | 'hide_all' | 'show_all'; -type RouteParams = { statusId: string }; + const node = useRef(null); + const statusRef = useRef(null); + const scroller = useRef(null); -interface IStatus extends RouteComponentProps, IntlComponentProps { - params: RouteParams, - dispatch: ThunkDispatch, - status: StatusEntity, - ancestorsIds: ImmutableOrderedSet, - descendantsIds: ImmutableOrderedSet, - askReplyConfirmation: boolean, - displayMedia: DisplayMedia, - allowedEmoji: ImmutableList, - onOpenMedia: (media: ImmutableList, index: number) => void, - onOpenVideo: (video: AttachmentEntity, time: number) => void, - me: Me, -} - -interface IStatusState { - fullscreen: boolean, - showMedia: boolean, - loadedStatusId?: string, - emojiSelectorFocused: boolean, - isLoaded: boolean, - error?: AxiosError, - next?: string, -} - -class Status extends ImmutablePureComponent { - - state = { - fullscreen: false, - showMedia: defaultMediaVisibility(this.props.status, this.props.displayMedia), - loadedStatusId: undefined, - emojiSelectorFocused: false, - isLoaded: Boolean(this.props.status), - error: undefined, - next: undefined, - }; - - node: HTMLDivElement | null = null; - status: HTMLDivElement | null = null; - scroller: VirtuosoHandle | null = null; - _scrolledIntoView: boolean = false; - - fetchData = async() => { - const { dispatch, params } = this.props; + /** Fetch the status (and context) from the API. */ + const fetchData = async() => { + const { params } = props; const { statusId } = params; const { next } = await dispatch(fetchStatusWithContext(statusId)); - this.setState({ next }); - } + setNext(next); + }; - componentDidMount() { - this.fetchData().then(() => { - this.setState({ isLoaded: true }); + // Load data. + useEffect(() => { + fetchData().then(() => { + setIsLoaded(true); }).catch(error => { - this.setState({ error, isLoaded: true }); + setIsLoaded(true); }); - attachFullscreenListener(this.onFullScreenChange); - } + }, [props.params.statusId]); - handleToggleMediaVisibility = () => { - this.setState({ showMedia: !this.state.showMedia }); - } + const handleToggleMediaVisibility = () => { + setShowMedia(!showMedia); + }; - handleEmojiReactClick = (status: StatusEntity, emoji: string) => { - this.props.dispatch(simpleEmojiReact(status, emoji)); - } + const handleHotkeyReact = () => { + if (statusRef.current) { + const firstEmoji: HTMLButtonElement | null = statusRef.current.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); + firstEmoji?.focus(); + } + }; - handleFavouriteClick = (status: StatusEntity) => { + const handleFavouriteClick = (status: StatusEntity) => { if (status.favourited) { - this.props.dispatch(unfavourite(status)); + dispatch(unfavourite(status)); } else { - this.props.dispatch(favourite(status)); + dispatch(favourite(status)); } - } + }; - handlePin = (status: StatusEntity) => { - if (status.pinned) { - this.props.dispatch(unpin(status)); - } else { - this.props.dispatch(pin(status)); - } - } - - handleBookmark = (status: StatusEntity) => { - if (status.bookmarked) { - this.props.dispatch(unbookmark(status)); - } else { - this.props.dispatch(bookmark(status)); - } - } - - handleReplyClick = (status: StatusEntity) => { - const { askReplyConfirmation, dispatch, intl } = this.props; + const handleReplyClick = (status: StatusEntity) => { if (askReplyConfirmation) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.replyMessage), @@ -279,276 +210,134 @@ class Status extends ImmutablePureComponent { } else { dispatch(replyCompose(status)); } - } + }; - handleModalReblog = (status: StatusEntity) => { - this.props.dispatch(reblog(status)); - } + const handleModalReblog = (status: StatusEntity) => { + dispatch(reblog(status)); + }; - handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => { - this.props.dispatch((_, getState) => { + const handleReblogClick = (status: StatusEntity, e?: React.MouseEvent) => { + dispatch((_, getState) => { const boostModal = getSettings(getState()).get('boostModal'); if (status.reblogged) { - this.props.dispatch(unreblog(status)); + dispatch(unreblog(status)); } else { if ((e && e.shiftKey) || !boostModal) { - this.handleModalReblog(status); + handleModalReblog(status); } else { - this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); + dispatch(openModal('BOOST', { status, onReblog: handleModalReblog })); } } }); - } + }; - handleQuoteClick = (status: StatusEntity) => { - const { askReplyConfirmation, dispatch, intl } = this.props; - if (askReplyConfirmation) { - dispatch(openModal('CONFIRM', { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(quoteCompose(status)), - })); - } else { - dispatch(quoteCompose(status)); - } - } + const handleMentionClick = (account: AccountEntity) => { + dispatch(mentionCompose(account)); + }; - handleDeleteClick = (status: StatusEntity, withRedraft = false) => { - const { dispatch, intl } = this.props; + const handleOpenMedia = (media: ImmutableList, index: number) => { + dispatch(openModal('MEDIA', { media, index })); + }; - this.props.dispatch((_, getState) => { - const deleteModal = getSettings(getState()).get('deleteModal'); - if (!deleteModal) { - dispatch(deleteStatus(status.id, withRedraft)); - } else { - dispatch(openModal('CONFIRM', { - icon: withRedraft ? require('@tabler/icons/edit.svg') : require('@tabler/icons/trash.svg'), - heading: intl.formatMessage(withRedraft ? messages.redraftHeading : messages.deleteHeading), - message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage), - confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm), - onConfirm: () => dispatch(deleteStatus(status.id, withRedraft)), - })); - } - }); - } + const handleOpenVideo = (media: ImmutableList, time: number) => { + dispatch(openModal('VIDEO', { media, time })); + }; - handleEditClick = (status: StatusEntity) => { - const { dispatch } = this.props; - - dispatch(editStatus(status.id)); - } - - handleDirectClick = (account: AccountEntity) => { - this.props.dispatch(directCompose(account)); - } - - handleChatClick = (account: AccountEntity, router: History) => { - this.props.dispatch(launchChat(account.id, router)); - } - - handleMentionClick = (account: AccountEntity) => { - this.props.dispatch(mentionCompose(account)); - } - - handleOpenMedia = (media: ImmutableList, index: number) => { - this.props.dispatch(openModal('MEDIA', { media, index })); - } - - handleOpenVideo = (media: ImmutableList, time: number) => { - this.props.dispatch(openModal('VIDEO', { media, time })); - } - - handleHotkeyOpenMedia = (e?: KeyboardEvent) => { - const { status, onOpenMedia, onOpenVideo } = this.props; - const firstAttachment = status.media_attachments.get(0); + const handleHotkeyOpenMedia = (e?: KeyboardEvent) => { + const { onOpenMedia, onOpenVideo } = props; + const firstAttachment = status?.media_attachments.get(0); e?.preventDefault(); - if (status.media_attachments.size > 0 && firstAttachment) { + if (status && firstAttachment) { if (firstAttachment.type === 'video') { onOpenVideo(firstAttachment, 0); } else { onOpenMedia(status.media_attachments, 0); } } - } - - handleMuteClick = (account: AccountEntity) => { - this.props.dispatch(initMuteModal(account)); - } - - handleConversationMuteClick = (status: StatusEntity) => { - if (status.muted) { - this.props.dispatch(unmuteStatus(status.id)); - } else { - this.props.dispatch(muteStatus(status.id)); - } - } - - handleToggleHidden = (status: StatusEntity) => { - if (status.hidden) { - this.props.dispatch(revealStatus(status.id)); - } else { - this.props.dispatch(hideStatus(status.id)); - } - } - - handleToggleAll = () => { - const { status, ancestorsIds, descendantsIds } = this.props; - const statusIds = [status.id].concat(ancestorsIds.toArray(), descendantsIds.toArray()); - - if (status.hidden) { - this.props.dispatch(revealStatus(statusIds)); - } else { - this.props.dispatch(hideStatus(statusIds)); - } - } - - handleBlockClick = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - const { account } = status; - if (!account || typeof account !== 'object') return; - - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: @{account.acct}
}} />, - confirm: intl.formatMessage(messages.blockConfirm), - onConfirm: () => dispatch(blockAccount(account.id)), - secondary: intl.formatMessage(messages.blockAndReport), - onSecondary: () => { - dispatch(blockAccount(account.id)); - dispatch(initReport(account, status)); - }, - })); - } - - handleReport = (status: StatusEntity) => { - this.props.dispatch(initReport(status.account as AccountEntity, status)); - } - - handleEmbed = (status: StatusEntity) => { - this.props.dispatch(openModal('EMBED', { url: status.url })); - } - - handleDeactivateUser = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string)); - } - - handleDeleteUser = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(deleteUserModal(intl, status.getIn(['account', 'id']) as string)); - } - - handleToggleStatusSensitivity = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(toggleStatusSensitivityModal(intl, status.id, status.sensitive)); - } - - handleDeleteStatus = (status: StatusEntity) => { - const { dispatch, intl } = this.props; - dispatch(deleteStatusModal(intl, status.id)); - } - - handleHotkeyMoveUp = () => { - this.handleMoveUp(this.props.status.id); - } - - handleHotkeyMoveDown = () => { - this.handleMoveDown(this.props.status.id); - } - - handleHotkeyReply = (e?: KeyboardEvent) => { - e?.preventDefault(); - this.handleReplyClick(this.props.status); - } - - handleHotkeyFavourite = () => { - this.handleFavouriteClick(this.props.status); - } - - handleHotkeyBoost = () => { - this.handleReblogClick(this.props.status); - } - - handleHotkeyMention = (e?: KeyboardEvent) => { - e?.preventDefault(); - const { account } = this.props.status; - if (!account || typeof account !== 'object') return; - this.handleMentionClick(account); - } - - handleHotkeyOpenProfile = () => { - this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); - } - - handleHotkeyToggleHidden = () => { - this.handleToggleHidden(this.props.status); - } - - handleHotkeyToggleSensitive = () => { - this.handleToggleMediaVisibility(); - } - - handleHotkeyReact = () => { - this._expandEmojiSelector(); - } - - handleMoveUp = (id: string) => { - const { status, ancestorsIds, descendantsIds } = this.props; - - if (id === status.id) { - this._selectChild(ancestorsIds.size - 1); - } else { - let index = ImmutableList(ancestorsIds).indexOf(id); - - if (index === -1) { - index = ImmutableList(descendantsIds).indexOf(id); - this._selectChild(ancestorsIds.size + index); - } else { - this._selectChild(index - 1); - } - } - } - - handleMoveDown = (id: string) => { - const { status, ancestorsIds, descendantsIds } = this.props; - - if (id === status.id) { - this._selectChild(ancestorsIds.size + 1); - } else { - let index = ImmutableList(ancestorsIds).indexOf(id); - - if (index === -1) { - index = ImmutableList(descendantsIds).indexOf(id); - this._selectChild(ancestorsIds.size + index + 2); - } else { - this._selectChild(index + 1); - } - } - } - - handleEmojiSelectorExpand: React.EventHandler = e => { - if (e.key === 'Enter') { - this._expandEmojiSelector(); - } - e.preventDefault(); - } - - handleEmojiSelectorUnfocus: React.EventHandler = () => { - this.setState({ emojiSelectorFocused: false }); - } - - _expandEmojiSelector = () => { - if (!this.status) return; - this.setState({ emojiSelectorFocused: true }); - const firstEmoji: HTMLButtonElement | null = this.status.querySelector('.emoji-react-selector .emoji-react-selector__emoji'); - firstEmoji?.focus(); }; - _selectChild(index: number) { - this.scroller?.scrollIntoView({ + const handleToggleHidden = (status: StatusEntity) => { + if (status.hidden) { + dispatch(revealStatus(status.id)); + } else { + dispatch(hideStatus(status.id)); + } + }; + + const handleHotkeyMoveUp = () => { + handleMoveUp(status!.id); + }; + + const handleHotkeyMoveDown = () => { + handleMoveDown(status!.id); + }; + + const handleHotkeyReply = (e?: KeyboardEvent) => { + e?.preventDefault(); + handleReplyClick(status!); + }; + + const handleHotkeyFavourite = () => { + handleFavouriteClick(status!); + }; + + const handleHotkeyBoost = () => { + handleReblogClick(status!); + }; + + const handleHotkeyMention = (e?: KeyboardEvent) => { + e?.preventDefault(); + const { account } = status!; + if (!account || typeof account !== 'object') return; + handleMentionClick(account); + }; + + const handleHotkeyOpenProfile = () => { + history.push(`/@${status!.getIn(['account', 'acct'])}`); + }; + + const handleHotkeyToggleHidden = () => { + handleToggleHidden(status!); + }; + + const handleHotkeyToggleSensitive = () => { + handleToggleMediaVisibility(); + }; + + const handleMoveUp = (id: string) => { + if (id === status?.id) { + _selectChild(ancestorsIds.size - 1); + } else { + let index = ImmutableList(ancestorsIds).indexOf(id); + + if (index === -1) { + index = ImmutableList(descendantsIds).indexOf(id); + _selectChild(ancestorsIds.size + index); + } else { + _selectChild(index - 1); + } + } + }; + + const handleMoveDown = (id: string) => { + if (id === status?.id) { + _selectChild(ancestorsIds.size + 1); + } else { + let index = ImmutableList(ancestorsIds).indexOf(id); + + if (index === -1) { + index = ImmutableList(descendantsIds).indexOf(id); + _selectChild(ancestorsIds.size + index + 2); + } else { + _selectChild(index + 1); + } + } + }; + + const _selectChild = (index: number) => { + scroller.current?.scrollIntoView({ index, behavior: 'smooth', done: () => { @@ -559,38 +348,32 @@ class Status extends ImmutablePureComponent { } }, }); - } + }; - renderTombstone(id: string) { + const renderTombstone = (id: string) => { return (
); - } - - renderStatus(id: string) { - const { status } = this.props; + }; + const renderStatus = (id: string) => { return ( ); - } + }; - renderPendingStatus(id: string) { - // const { status } = this.props; + const renderPendingStatus = (id: string) => { const idempotencyKey = id.replace(/^末pending-/, ''); return ( @@ -598,229 +381,162 @@ class Status extends ImmutablePureComponent { className='thread__status' key={id} idempotencyKey={idempotencyKey} - // focusedStatusId={status.id} - // onMoveUp={this.handleMoveUp} - // onMoveDown={this.handleMoveDown} - // contextType='thread' /> ); - } + }; - renderChildren(list: ImmutableOrderedSet) { + const renderChildren = (list: ImmutableOrderedSet) => { return list.map(id => { if (id.endsWith('-tombstone')) { - return this.renderTombstone(id); + return renderTombstone(id); } else if (id.startsWith('末pending-')) { - return this.renderPendingStatus(id); + return renderPendingStatus(id); } else { - return this.renderStatus(id); + return renderStatus(id); } }); - } + }; - setRef: React.RefCallback = c => { - this.node = c; - } + // Reset media visibility if status changes. + useEffect(() => { + setShowMedia(defaultMediaVisibility(status, displayMedia)); + }, [status?.id]); - setStatusRef: React.RefCallback = c => { - this.status = c; - } + // Scroll focused status into view when thread updates. + useEffect(() => { + scroller.current?.scrollToIndex({ + index: ancestorsIds.size, + offset: -80, + }); - componentDidUpdate(prevProps: IStatus, prevState: IStatusState) { - const { params, status, displayMedia, ancestorsIds } = this.props; - const { isLoaded } = this.state; + setImmediate(() => statusRef.current?.querySelector('.detailed-status')?.focus()); + }, [props.params.statusId, status?.id, ancestorsIds.size, isLoaded]); - if (params.statusId !== prevProps.params.statusId) { - this.fetchData(); - } + const handleRefresh = () => { + return fetchData(); + }; - if (status && status.id !== prevState.loadedStatusId) { - this.setState({ showMedia: defaultMediaVisibility(status, displayMedia), loadedStatusId: status.id }); - } - - if (params.statusId !== prevProps.params.statusId || status?.id !== prevProps.status?.id || ancestorsIds.size > prevProps.ancestorsIds.size || isLoaded !== prevState.isLoaded) { - this.scroller?.scrollToIndex({ - index: this.props.ancestorsIds.size, - offset: -80, - }); - - setImmediate(() => this.status?.querySelector('.detailed-status')?.focus()); - } - } - - componentWillUnmount() { - detachFullscreenListener(this.onFullScreenChange); - } - - onFullScreenChange = () => { - this.setState({ fullscreen: isFullscreen() }); - } - - handleRefresh = () => { - return this.fetchData(); - } - - handleLoadMore = () => { - const { status } = this.props; - const { next } = this.state; - - if (next) { - this.props.dispatch(fetchNext(status.id, next)).then(({ next }) => { - this.setState({ next }); + const handleLoadMore = useCallback(debounce(() => { + if (next && status) { + dispatch(fetchNext(status.id, next)).then(({ next }) => { + setNext(next); }).catch(() => {}); } - } - - handleOpenCompareHistoryModal = (status: StatusEntity) => { - const { dispatch } = this.props; + }, 300, { leading: true }), [next, status]); + const handleOpenCompareHistoryModal = (status: StatusEntity) => { dispatch(openModal('COMPARE_HISTORY', { statusId: status.id, })); - } + }; - setScrollerRef = (c: VirtuosoHandle) => { - this.scroller = c; - } - - render() { - const { me, status, ancestorsIds, descendantsIds, intl } = this.props; - - const hasAncestors = ancestorsIds && ancestorsIds.size > 0; - const hasDescendants = descendantsIds && descendantsIds.size > 0; - - if (!status && this.state.isLoaded) { - // TODO: handle errors other than 404 with `this.state.error?.response?.status` - return ( - - ); - } else if (!status) { - return ( - - ); - } - - type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; - - const handlers: HotkeyHandlers = { - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - openProfile: this.handleHotkeyOpenProfile, - toggleHidden: this.handleHotkeyToggleHidden, - toggleSensitive: this.handleHotkeyToggleSensitive, - openMedia: this.handleHotkeyOpenMedia, - react: this.handleHotkeyReact, - }; - - const username = String(status.getIn(['account', 'acct'])); - const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title; - - const focusedStatus = ( -
- -
- {/* @ts-ignore */} - - -
- - -
-
- - {hasDescendants && ( -
- )} -
- ); - - const children: JSX.Element[] = []; - - if (hasAncestors) { - children.push(...this.renderChildren(ancestorsIds).toArray()); - } - - children.push(focusedStatus); - - if (hasDescendants) { - children.push(...this.renderChildren(descendantsIds).toArray()); - } + const hasAncestors = ancestorsIds.size > 0; + const hasDescendants = descendantsIds.size > 0; + if (!status && isLoaded) { return ( - -
- -
- - - -
- } - initialTopMostItemIndex={ancestorsIds.size} - > - {children} - -
- - {!me && } -
-
-
+ + ); + } else if (!status) { + return ( + ); } -} + type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; -const WrappedComponent = withRouter(injectIntl(Status)); -// @ts-ignore -export default connect(makeMapStateToProps)(WrappedComponent); + const handlers: HotkeyHandlers = { + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + reply: handleHotkeyReply, + favourite: handleHotkeyFavourite, + boost: handleHotkeyBoost, + mention: handleHotkeyMention, + openProfile: handleHotkeyOpenProfile, + toggleHidden: handleHotkeyToggleHidden, + toggleSensitive: handleHotkeyToggleSensitive, + openMedia: handleHotkeyOpenMedia, + react: handleHotkeyReact, + }; + + const username = String(status.getIn(['account', 'acct'])); + const titleMessage = status.visibility === 'direct' ? messages.titleDirect : messages.title; + + const focusedStatus = ( +
+ +
+ + +
+ + +
+
+ + {hasDescendants && ( +
+ )} +
+ ); + + const children: JSX.Element[] = []; + + if (hasAncestors) { + children.push(...renderChildren(ancestorsIds).toArray()); + } + + children.push(focusedStatus); + + if (hasDescendants) { + children.push(...renderChildren(descendantsIds).toArray()); + } + + return ( + +
+ +
+ + + +
+ } + initialTopMostItemIndex={ancestorsIds.size} + > + {children} + +
+ + {!me && } +
+
+
+ ); +}; + +export default Thread; diff --git a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx index 487a84bfb..0d203ab9b 100644 --- a/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx +++ b/app/soapbox/features/ui/components/__tests__/trends-panel.test.tsx @@ -1,85 +1,74 @@ -import { List as ImmutableList, Record as ImmutableRecord } from 'immutable'; import React from 'react'; -import { render, screen } from '../../../../jest/test-helpers'; -import { normalizeTag } from '../../../../normalizers'; +import { __stub } from 'soapbox/api'; + +import { queryClient, render, screen, waitFor } from '../../../../jest/test-helpers'; import TrendsPanel from '../trends-panel'; describe('', () => { - it('renders trending hashtags', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([ - normalizeTag({ - name: 'hashtag 1', - history: [{ - day: '1652745600', - uses: '294', - accounts: '180', - }], - }), - ]), - isLoading: false, - })(), - }; - - render(, undefined, store); - expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i); - expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i); - expect(screen.getByTestId('sparklines')).toBeInTheDocument(); + beforeEach(() => { + queryClient.clear(); }); - it('renders multiple trends', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([ - normalizeTag({ - name: 'hashtag 1', - history: ImmutableList([{ accounts: [] }]), - }), - normalizeTag({ - name: 'hashtag 2', - history: ImmutableList([{ accounts: [] }]), - }), - ]), - isLoading: false, - })(), - }; + describe('with hashtags', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends') + .reply(200, [ + { + name: 'hashtag 1', + url: 'https://example.com', + history: [{ + day: '1652745600', + uses: '294', + accounts: '180', + }], + }, + { name: 'hashtag 2', url: 'https://example.com' }, + ]); + }); + }); - render(, undefined, store); - expect(screen.queryAllByTestId('hashtag')).toHaveLength(2); + it('renders trending hashtags', async() => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('hashtag')).toHaveTextContent(/hashtag 1/i); + expect(screen.getByTestId('hashtag')).toHaveTextContent(/180 people talking/i); + expect(screen.getByTestId('sparklines')).toBeInTheDocument(); + }); + }); + + it('renders multiple trends', async() => { + render(); + + await waitFor(() => { + expect(screen.queryAllByTestId('hashtag')).toHaveLength(2); + }); + }); + + it('respects the limit prop', async() => { + render(); + + await waitFor(() => { + expect(screen.queryAllByTestId('hashtag')).toHaveLength(1); + }); + }); }); - it('respects the limit prop', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([ - normalizeTag({ - name: 'hashtag 1', - history: [{ accounts: [] }], - }), - normalizeTag({ - name: 'hashtag 2', - history: [{ accounts: [] }], - }), - ]), - isLoading: false, - })(), - }; + describe('without hashtags', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends').reply(200, []); + }); + }); - render(, undefined, store); - expect(screen.queryAllByTestId('hashtag')).toHaveLength(1); - }); + it('renders empty', async() => { + render(); - it('renders empty', () => { - const store = { - trends: ImmutableRecord({ - items: ImmutableList([]), - isLoading: false, - })(), - }; - - render(, undefined, store); - expect(screen.queryAllByTestId('hashtag')).toHaveLength(0); + await waitFor(() => { + expect(screen.queryAllByTestId('hashtag')).toHaveLength(0); + }); + }); }); }); diff --git a/app/soapbox/features/ui/components/trends-panel.tsx b/app/soapbox/features/ui/components/trends-panel.tsx index 49b68887d..9f582d891 100644 --- a/app/soapbox/features/ui/components/trends-panel.tsx +++ b/app/soapbox/features/ui/components/trends-panel.tsx @@ -1,40 +1,24 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; -import { useDispatch } from 'react-redux'; -import { fetchTrends } from 'soapbox/actions/trends'; import Hashtag from 'soapbox/components/hashtag'; import { Widget } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; +import useTrends from 'soapbox/queries/trends'; interface ITrendsPanel { limit: number } const TrendsPanel = ({ limit }: ITrendsPanel) => { - const dispatch = useDispatch(); + const { data: trends, isFetching } = useTrends(); - const trends = useAppSelector((state) => state.trends.items); - - const sortedTrends = React.useMemo(() => { - return trends.sort((a, b) => { - const num_a = Number(a.getIn(['history', 0, 'accounts'])); - const num_b = Number(b.getIn(['history', 0, 'accounts'])); - return num_b - num_a; - }).slice(0, limit); - }, [trends, limit]); - - React.useEffect(() => { - dispatch(fetchTrends()); - }, []); - - if (sortedTrends.isEmpty()) { + if (trends?.length === 0 || isFetching) { return null; } return ( }> - {sortedTrends.map((hashtag) => ( + {trends?.slice(0, limit).map((hashtag) => ( ))} diff --git a/app/soapbox/jest/test-helpers.tsx b/app/soapbox/jest/test-helpers.tsx index 0894d4d40..721783879 100644 --- a/app/soapbox/jest/test-helpers.tsx +++ b/app/soapbox/jest/test-helpers.tsx @@ -37,6 +37,8 @@ const queryClient = new QueryClient({ }, defaultOptions: { queries: { + staleTime: 0, + cacheTime: Infinity, retry: false, }, }, @@ -123,4 +125,5 @@ export { rootReducer, mockWindowProperty, createTestStore, + queryClient, }; diff --git a/app/soapbox/queries/__tests__/suggestions.test.ts b/app/soapbox/queries/__tests__/suggestions.test.ts new file mode 100644 index 000000000..f38bf0dbc --- /dev/null +++ b/app/soapbox/queries/__tests__/suggestions.test.ts @@ -0,0 +1,44 @@ +import { __stub } from 'soapbox/api'; +import { renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import useOnboardingSuggestions from '../suggestions'; + +describe('useCarouselAvatars', () => { + describe('with a successful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v2/suggestions') + .reply(200, [ + { source: 'staff', account: { id: '1', acct: 'a', account_avatar: 'https://example.com/some.jpg' } }, + { source: 'staff', account: { id: '2', acct: 'b', account_avatar: 'https://example.com/some.jpg' } }, + ], { + link: '; rel=\'prev\'', + }); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useOnboardingSuggestions()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data?.length).toBe(2); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v2/suggestions').networkError(); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useOnboardingSuggestions()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/app/soapbox/queries/__tests__/trends.test.ts b/app/soapbox/queries/__tests__/trends.test.ts new file mode 100644 index 000000000..784f928cb --- /dev/null +++ b/app/soapbox/queries/__tests__/trends.test.ts @@ -0,0 +1,46 @@ +import { __stub } from 'soapbox/api'; +import { queryClient, renderHook, waitFor } from 'soapbox/jest/test-helpers'; + +import useTrends from '../trends'; + +describe('useTrends', () => { + beforeEach(() => { + queryClient.clear(); + }); + + describe('with a successful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends') + .reply(200, [ + { name: '#golf', url: 'https://example.com' }, + { name: '#tennis', url: 'https://example.com' }, + ]); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useTrends()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data?.length).toBe(2); + }); + }); + + describe('with an unsuccessful query', () => { + beforeEach(() => { + __stub((mock) => { + mock.onGet('/api/v1/trends').networkError(); + }); + }); + + it('is successful', async() => { + const { result } = renderHook(() => useTrends()); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.error).toBeDefined(); + }); + }); +}); diff --git a/app/soapbox/queries/suggestions.ts b/app/soapbox/queries/suggestions.ts new file mode 100644 index 000000000..d5ddf07bf --- /dev/null +++ b/app/soapbox/queries/suggestions.ts @@ -0,0 +1,81 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { fetchRelationships } from 'soapbox/actions/accounts'; +import { importFetchedAccounts } from 'soapbox/actions/importer'; +import { getLinks } from 'soapbox/api'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; + +type Account = { + acct: string + avatar: string + avatar_static: string + bot: boolean + created_at: string + discoverable: boolean + display_name: string + followers_count: number + following_count: number + group: boolean + header: string + header_static: string + id: string + last_status_at: string + location: string + locked: boolean + note: string + statuses_count: number + url: string + username: string + verified: boolean + website: string +} + +type Suggestion = { + source: 'staff' + account: Account +} + + +export default function useOnboardingSuggestions() { + const api = useApi(); + const dispatch = useAppDispatch(); + + const getV2Suggestions = async(pageParam: any): Promise<{ data: Suggestion[], link: string | undefined, hasMore: boolean }> => { + const link = pageParam?.link || '/api/v2/suggestions'; + const response = await api.get(link); + const hasMore = !!response.headers.link; + const nextLink = getLinks(response).refs.find(link => link.rel === 'next')?.uri; + + const accounts = response.data.map(({ account }) => account); + const accountIds = accounts.map((account) => account.id); + dispatch(importFetchedAccounts(accounts)); + dispatch(fetchRelationships(accountIds)); + + return { + data: response.data, + link: nextLink, + hasMore, + }; + }; + + const result = useInfiniteQuery(['suggestions', 'v2'], ({ pageParam }) => getV2Suggestions(pageParam), { + keepPreviousData: true, + getNextPageParam: (config) => { + if (config.hasMore) { + return { link: config.link }; + } + + return undefined; + }, + }); + + const data = result.data?.pages.reduce( + (prev: Suggestion[], curr) => [...prev, ...curr.data], + [], + ); + + return { + ...result, + data, + }; +} diff --git a/app/soapbox/queries/trends.ts b/app/soapbox/queries/trends.ts new file mode 100644 index 000000000..afe780991 --- /dev/null +++ b/app/soapbox/queries/trends.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; + +import { fetchTrendsSuccess } from 'soapbox/actions/trends'; +import { useApi, useAppDispatch } from 'soapbox/hooks'; +import { normalizeTag } from 'soapbox/normalizers'; + +import type { Tag } from 'soapbox/types/entities'; + +export default function useTrends() { + const api = useApi(); + const dispatch = useAppDispatch(); + + const getTrends = async() => { + const { data } = await api.get('/api/v1/trends'); + + dispatch(fetchTrendsSuccess(data)); + + const normalizedData = data.map((tag) => normalizeTag(tag)); + return normalizedData; + }; + + const result = useQuery>(['trends'], getTrends, { + placeholderData: [], + staleTime: 600000, // 10 minutes + }); + + return result; +} diff --git a/app/soapbox/utils/status.ts b/app/soapbox/utils/status.ts index 439edfc02..c78d2fe6c 100644 --- a/app/soapbox/utils/status.ts +++ b/app/soapbox/utils/status.ts @@ -1,9 +1,10 @@ import { isIntegerId } from 'soapbox/utils/numbers'; +import type { IntlShape } from 'react-intl'; import type { Status as StatusEntity } from 'soapbox/types/entities'; /** Get the initial visibility of media attachments from user settings. */ -export const defaultMediaVisibility = (status: StatusEntity | undefined, displayMedia: string): boolean => { +export const defaultMediaVisibility = (status: StatusEntity | undefined | null, displayMedia: string): boolean => { if (!status) return false; if (status.reblog && typeof status.reblog === 'object') { @@ -36,3 +37,38 @@ export const shouldHaveCard = (status: StatusEntity): boolean => { export const hasIntegerMediaIds = (status: StatusEntity): boolean => { return status.media_attachments.some(({ id }) => isIntegerId(id)); }; + +/** Sanitize status text for use with screen readers. */ +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 ? 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']), + ]; + + if (rebloggedByText) { + values.push(rebloggedByText); + } + + return values.join(', '); +}; + +/** Get reblogged status if any, otherwise return the original status. */ +// @ts-ignore The type seems right, but TS doesn't like it. +export const getActualStatus: { + (status: StatusEntity): StatusEntity, + (status: undefined): undefined, + (status: null): null, +} = (status) => { + if (status?.reblog && typeof status?.reblog === 'object') { + return status.reblog as StatusEntity; + } else { + return status; + } +}; diff --git a/app/styles/components/status.scss b/app/styles/components/status.scss index fbd5d6c26..0cb4a5e78 100644 --- a/app/styles/components/status.scss +++ b/app/styles/components/status.scss @@ -54,6 +54,10 @@ padding: 8px 12px; margin-bottom: 20px; word-break: break-all; + + &:last-child { + margin-bottom: 0; + } } /* Markdown images */ diff --git a/webpack/production.js b/webpack/production.js index 43792e97c..9bd16e045 100644 --- a/webpack/production.js +++ b/webpack/production.js @@ -92,7 +92,10 @@ module.exports = merge(sharedConfig, { cacheMaps: [{ // NOTE: This function gets stringified by OfflinePlugin, so don't try // moving it anywhere else or making it depend on anything outside it! - match: ({ pathname }) => { + // https://github.com/NekR/offline-plugin/blob/master/docs/cache-maps.md + match: (url) => { + const { pathname } = url; + const backendRoutes = [ '/.well-known', '/activities', @@ -119,10 +122,8 @@ module.exports = merge(sharedConfig, { '/unsubscribe', ]; - if (pathname) { - return backendRoutes.some(path => pathname.startsWith(path)); - } else { - return false; + if (backendRoutes.some(path => pathname.startsWith(path)) || pathname.endsWith('/embed')) { + return url; } }, requestTypes: ['navigate'],