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/accounts.ts b/app/soapbox/actions/accounts.ts index c3bc56557..a18367f1b 100644 --- a/app/soapbox/actions/accounts.ts +++ b/app/soapbox/actions/accounts.ts @@ -227,7 +227,12 @@ const fetchAccountFail = (id: string | null, error: AxiosError) => ({ skipAlert: true, }); -const followAccount = (id: string, options = { reblogs: true }) => +type FollowAccountOpts = { + reblogs?: boolean, + notify?: boolean +}; + +const followAccount = (id: string, options?: FollowAccountOpts) => (dispatch: AppDispatch, getState: () => RootState) => { if (!isLoggedIn(getState)) return null; 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/reports.ts b/app/soapbox/actions/reports.ts index 40b685ba4..dce162247 100644 --- a/app/soapbox/actions/reports.ts +++ b/app/soapbox/actions/reports.ts @@ -20,7 +20,7 @@ const REPORT_BLOCK_CHANGE = 'REPORT_BLOCK_CHANGE'; const REPORT_RULE_CHANGE = 'REPORT_RULE_CHANGE'; -const initReport = (account: Account, status: Status) => +const initReport = (account: Account, status?: Status) => (dispatch: AppDispatch) => { dispatch({ type: REPORT_INIT, @@ -121,4 +121,4 @@ export { changeReportForward, changeReportBlock, changeReportRule, -}; \ No newline at end of file +}; 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/account/components/header.js b/app/soapbox/features/account/components/header.js deleted file mode 100644 index aaeed52ee..000000000 --- a/app/soapbox/features/account/components/header.js +++ /dev/null @@ -1,661 +0,0 @@ -'use strict'; - -import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; -import debounce from 'lodash/debounce'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; - -import { openModal } from 'soapbox/actions/modals'; -import Avatar from 'soapbox/components/avatar'; -import Badge from 'soapbox/components/badge'; -import StillImage from 'soapbox/components/still_image'; -import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider } from 'soapbox/components/ui'; -import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; -import ActionButton from 'soapbox/features/ui/components/action-button'; -import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; -import { - isLocal, - isRemote, -} from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -const messages = defineMessages({ - edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, - linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, - account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' }, - mention: { id: 'account.mention', defaultMessage: 'Mention' }, - chat: { id: 'account.chat', defaultMessage: 'Chat with @{name}' }, - direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' }, - unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, - block: { id: 'account.block', defaultMessage: 'Block @{name}' }, - unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, - mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, - report: { id: 'account.report', defaultMessage: 'Report @{name}' }, - share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, - media: { id: 'account.media', defaultMessage: 'Media' }, - blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, - unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, - hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide reposts from @{name}' }, - showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show reposts from @{name}' }, - preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, - follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, - blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, - domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, - mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, - endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, - unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, - removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' }, - admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, - add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, - deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, - deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, - verifyUser: { id: 'admin.users.actions.verify_user', defaultMessage: 'Verify @{name}' }, - unverifyUser: { id: 'admin.users.actions.unverify_user', defaultMessage: 'Unverify @{name}' }, - setDonor: { id: 'admin.users.actions.set_donor', defaultMessage: 'Set @{name} as a donor' }, - removeDonor: { id: 'admin.users.actions.remove_donor', defaultMessage: 'Remove @{name} as a donor' }, - promoteToAdmin: { id: 'admin.users.actions.promote_to_admin', defaultMessage: 'Promote @{name} to an admin' }, - promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' }, - demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' }, - demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' }, - suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' }, - unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' }, - search: { id: 'account.search', defaultMessage: 'Search from @{name}' }, -}); - -const mapStateToProps = state => { - const me = state.get('me'); - const account = state.getIn(['accounts', me]); - const instance = state.get('instance'); - const features = getFeatures(instance); - - return { - me, - meAccount: account, - features, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Header extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record, - meaccount: ImmutablePropTypes.record, - intl: PropTypes.object.isRequired, - username: PropTypes.string, - features: PropTypes.object, - }; - - state = { - isSmallScreen: (window.innerWidth <= 895), - } - - isStatusesPageActive = (match, location) => { - if (!match) { - return false; - } - - return !location.pathname.match(/\/(followers|following|favorites|pins)\/?$/); - } - - componentDidMount() { - window.addEventListener('resize', this.handleResize, { passive: true }); - } - - componentWillUnmount() { - window.removeEventListener('resize', this.handleResize); - } - - handleResize = debounce(() => { - this.setState({ isSmallScreen: (window.innerWidth <= 895) }); - }, 5, { - trailing: true, - }); - - onAvatarClick = () => { - const avatar_url = this.props.account.get('avatar'); - const avatar = ImmutableMap({ - type: 'image', - preview_url: avatar_url, - url: avatar_url, - description: '', - }); - this.props.dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 })); - } - - handleAvatarClick = (e) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.onAvatarClick(); - } - } - - onHeaderClick = () => { - const header_url = this.props.account.get('header'); - const header = ImmutableMap({ - type: 'image', - preview_url: header_url, - url: header_url, - description: '', - }); - this.props.dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 })); - } - - handleHeaderClick = (e) => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.onHeaderClick(); - } - } - - handleShare = () => { - navigator.share({ - text: `@${this.props.account.get('acct')}`, - url: this.props.account.get('url'), - }).catch((e) => { - if (e.name !== 'AbortError') console.error(e); - }); - } - - makeMenu() { - const { account, intl, me, meAccount, features } = this.props; - - const menu = []; - - if (!account || !me) { - return []; - } - - if ('share' in navigator) { - menu.push({ - text: intl.formatMessage(messages.share, { name: account.get('username') }), - action: this.handleShare, - icon: require('@tabler/icons/upload.svg'), - }); - menu.push(null); - } - - if (account.get('id') === me) { - menu.push({ - text: intl.formatMessage(messages.edit_profile), - to: '/settings/profile', - icon: require('@tabler/icons/user.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.preferences), - to: '/settings', - icon: require('@tabler/icons/settings.svg'), - }); - // menu.push(null); - // menu.push({ - // text: intl.formatMessage(messages.follow_requests), - // to: '/follow_requests', - // icon: require('@tabler/icons/user-plus.svg'), - // }); - menu.push(null); - menu.push({ - text: intl.formatMessage(messages.mutes), - to: '/mutes', - icon: require('@tabler/icons/circle-x.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.blocks), - to: '/blocks', - icon: require('@tabler/icons/ban.svg'), - }); - // menu.push({ - // text: intl.formatMessage(messages.domain_blocks), - // to: '/domain_blocks', - // icon: require('@tabler/icons/ban.svg'), - // }); - } else { - menu.push({ - text: intl.formatMessage(messages.mention, { name: account.get('username') }), - action: this.props.onMention, - icon: require('@tabler/icons/at.svg'), - }); - - // if (account.getIn(['pleroma', 'accepts_chat_messages'], false) === true) { - // menu.push({ - // text: intl.formatMessage(messages.chat, { name: account.get('username') }), - // action: this.props.onChat, - // icon: require('@tabler/icons/messages.svg'), - // }); - // } else { - // menu.push({ - // text: intl.formatMessage(messages.direct, { name: account.get('username') }), - // action: this.props.onDirect, - // icon: require('@tabler/icons/mail.svg'), - // }); - // } - - if (account.relationship?.following) { - if (account.relationship?.showing_reblogs) { - menu.push({ - text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), - action: this.props.onReblogToggle, - icon: require('@tabler/icons/repeat.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), - action: this.props.onReblogToggle, - icon: require('@tabler/icons/repeat.svg'), - }); - } - - if (features.lists) { - menu.push({ - text: intl.formatMessage(messages.add_or_remove_from_list), - action: this.props.onAddToList, - icon: require('@tabler/icons/list.svg'), - }); - } - - if (features.accountEndorsements) { - menu.push({ - text: intl.formatMessage(account.relationship?.endorsed ? messages.unendorse : messages.endorse), - action: this.props.onEndorseToggle, - icon: require('@tabler/icons/user-check.svg'), - }); - } - - menu.push(null); - } else if (features.lists && features.unrestrictedLists) { - menu.push({ - text: intl.formatMessage(messages.add_or_remove_from_list), - action: this.props.onAddToList, - icon: require('@tabler/icons/list.svg'), - }); - } - - if (features.searchFromAccount) { - menu.push({ - text: intl.formatMessage(messages.search, { name: account.get('username') }), - action: this.props.onSearch, - icon: require('@tabler/icons/search.svg'), - }); - } - - if (features.removeFromFollowers && account.relationship?.followed_by) { - menu.push({ - text: intl.formatMessage(messages.removeFromFollowers), - action: this.props.onRemoveFromFollowers, - icon: require('@tabler/icons/user-x.svg'), - }); - } - - if (account.relationship?.muting) { - menu.push({ - text: intl.formatMessage(messages.unmute, { name: account.get('username') }), - action: this.props.onMute, - icon: require('@tabler/icons/circle-x.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.mute, { name: account.get('username') }), - action: this.props.onMute, - icon: require('@tabler/icons/circle-x.svg'), - }); - } - - if (account.relationship?.blocking) { - menu.push({ - text: intl.formatMessage(messages.unblock, { name: account.get('username') }), - action: this.props.onBlock, - icon: require('@tabler/icons/ban.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.block, { name: account.get('username') }), - action: this.props.onBlock, - icon: require('@tabler/icons/ban.svg'), - }); - } - - menu.push({ - text: intl.formatMessage(messages.report, { name: account.get('username') }), - action: this.props.onReport, - icon: require('@tabler/icons/flag.svg'), - }); - } - - if (isRemote(account)) { - const domain = account.fqn.split('@')[1]; - - menu.push(null); - - if (account.relationship?.domain_blocking) { - menu.push({ - text: intl.formatMessage(messages.unblockDomain, { domain }), - action: this.props.onUnblockDomain, - icon: require('@tabler/icons/ban.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.blockDomain, { domain }), - action: this.props.onBlockDomain, - icon: require('@tabler/icons/ban.svg'), - }); - } - } - - if (meAccount.staff) { - menu.push(null); - - if (meAccount.admin) { - menu.push({ - text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), - to: `/pleroma/admin/#/users/${account.id}/`, - newTab: true, - icon: require('@tabler/icons/gavel.svg'), - }); - } - - if (account.id !== me && isLocal(account) && meAccount.admin) { - if (account.admin) { - menu.push({ - text: intl.formatMessage(messages.demoteToModerator, { name: account.get('username') }), - action: this.props.onPromoteToModerator, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.demoteToUser, { name: account.get('username') }), - action: this.props.onDemoteToUser, - icon: require('@tabler/icons/arrow-down-circle.svg'), - }); - } else if (account.moderator) { - menu.push({ - text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }), - action: this.props.onPromoteToAdmin, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.demoteToUser, { name: account.get('username') }), - action: this.props.onDemoteToUser, - icon: require('@tabler/icons/arrow-down-circle.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }), - action: this.props.onPromoteToAdmin, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.promoteToModerator, { name: account.get('username') }), - action: this.props.onPromoteToModerator, - icon: require('@tabler/icons/arrow-up-circle.svg'), - }); - } - } - - if (account.verified) { - menu.push({ - text: intl.formatMessage(messages.unverifyUser, { name: account.username }), - action: this.props.onUnverifyUser, - icon: require('@tabler/icons/check.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.verifyUser, { name: account.username }), - action: this.props.onVerifyUser, - icon: require('@tabler/icons/check.svg'), - }); - } - - if (account.donor) { - menu.push({ - text: intl.formatMessage(messages.removeDonor, { name: account.username }), - action: this.props.onRemoveDonor, - icon: require('@tabler/icons/coin.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.setDonor, { name: account.username }), - action: this.props.onSetDonor, - icon: require('@tabler/icons/coin.svg'), - }); - } - - if (features.suggestionsV2 && meAccount.admin) { - if (account.getIn(['pleroma', 'is_suggested'])) { - menu.push({ - text: intl.formatMessage(messages.unsuggestUser, { name: account.get('username') }), - action: this.props.onUnsuggestUser, - icon: require('@tabler/icons/user-x.svg'), - }); - } else { - menu.push({ - text: intl.formatMessage(messages.suggestUser, { name: account.get('username') }), - action: this.props.onSuggestUser, - icon: require('@tabler/icons/user-check.svg'), - }); - } - } - - if (account.get('id') !== me) { - menu.push({ - text: intl.formatMessage(messages.deactivateUser, { name: account.get('username') }), - action: this.props.onDeactivateUser, - icon: require('@tabler/icons/user-off.svg'), - }); - menu.push({ - text: intl.formatMessage(messages.deleteUser, { name: account.get('username') }), - icon: require('@tabler/icons/user-minus.svg'), - }); - } - } - - return menu; - } - - makeInfo() { - const { account, me } = this.props; - - const info = []; - - if (!account || !me) return info; - - if (me !== account.get('id') && account.relationship?.followed_by) { - info.push( - } - />, - ); - } else if (me !== account.get('id') && account.relationship?.blocking) { - info.push( - } - />, - ); - } - - if (me !== account.get('id') && account.relationship?.muting) { - info.push( - } - />, - ); - } else if (me !== account.get('id') && account.relationship?.domain_blocking) { - info.push( - } - />, - ); - } - - return info; - } - - renderMessageButton() { - const { intl, account, me } = this.props; - - if (!me || !account || account.get('id') === me) { - return null; - } - - const canChat = account.getIn(['pleroma', 'accepts_chat_messages'], false) === true; - - if (canChat) { - return ( - - ); - } else { - return ( - - ); - } - } - - renderShareButton() { - const { intl, account, me } = this.props; - const canShare = 'share' in navigator; - - if (!(account && me && account.get('id') === me && canShare)) { - return null; - } - - return ( - - ); - } - - render() { - const { account, me } = this.props; - - if (!account) { - return ( -
-
-
-
- -
-
-
-
-
-
-
-
- ); - } - - const info = this.makeInfo(); - const menu = this.makeMenu(); - const header = account.get('header', ''); - - return ( -
-
-
- {header && ( - - - - )} - -
- - {info} - -
-
-
- -
-
-
- - - -
- -
-
- - - {me && ( - - - - - {menu.map((menuItem, idx) => { - if (typeof menuItem?.text === 'undefined') { - return ; - } else { - const Comp = menuItem.action ? MenuItem : MenuLink; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; - - return ( - -
- - -
{menuItem.text}
-
-
- ); - } - })} -
-
- )} - - {this.renderShareButton()} - {/* {this.renderMessageButton()} */} - - -
-
-
-
-
- ); - } - -} diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx new file mode 100644 index 000000000..58b777e97 --- /dev/null +++ b/app/soapbox/features/account/components/header.tsx @@ -0,0 +1,815 @@ +'use strict'; + +import { List as ImmutableList, Map as ImmutableMap } from 'immutable'; +import React from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { Link, useHistory } from 'react-router-dom'; + +import { blockAccount, followAccount, pinAccount, removeFromFollowers, unblockAccount, unmuteAccount, unpinAccount } from 'soapbox/actions/accounts'; +import { verifyUser, unverifyUser, setDonor, removeDonor, promoteToAdmin, promoteToModerator, demoteToUser, suggestUsers, unsuggestUsers } from 'soapbox/actions/admin'; +import { launchChat } from 'soapbox/actions/chats'; +import { mentionCompose, directCompose } from 'soapbox/actions/compose'; +import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks'; +import { openModal } from 'soapbox/actions/modals'; +import { deactivateUserModal } from 'soapbox/actions/moderation'; +import { initMuteModal } from 'soapbox/actions/mutes'; +import { initReport } from 'soapbox/actions/reports'; +import { setSearchAccount } from 'soapbox/actions/search'; +import { getSettings } from 'soapbox/actions/settings'; +import snackbar from 'soapbox/actions/snackbar'; +import Avatar from 'soapbox/components/avatar'; +import Badge from 'soapbox/components/badge'; +import StillImage from 'soapbox/components/still_image'; +import { HStack, IconButton, Menu, MenuButton, MenuItem, MenuList, MenuLink, MenuDivider } from 'soapbox/components/ui'; +import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; +import MovedNote from 'soapbox/features/account_timeline/components/moved_note'; +import ActionButton from 'soapbox/features/ui/components/action-button'; +import SubscriptionButton from 'soapbox/features/ui/components/subscription-button'; +import { useAppDispatch, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { Account } from 'soapbox/types/entities'; +import { + isLocal, + isRemote, +} from 'soapbox/utils/accounts'; + +import type { Menu as MenuType } from 'soapbox/components/dropdown_menu'; + +const messages = defineMessages({ + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, + account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' }, + mention: { id: 'account.mention', defaultMessage: 'Mention' }, + chat: { id: 'account.chat', defaultMessage: 'Chat with @{name}' }, + direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' }, + unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, + block: { id: 'account.block', defaultMessage: 'Block @{name}' }, + unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, + report: { id: 'account.report', defaultMessage: 'Report @{name}' }, + share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' }, + media: { id: 'account.media', defaultMessage: 'Media' }, + blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, + unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide reposts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show reposts from @{name}' }, + preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, + follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, + blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, + domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, + mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, + endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, + unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, + removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, + deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, + deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, + verifyUser: { id: 'admin.users.actions.verify_user', defaultMessage: 'Verify @{name}' }, + unverifyUser: { id: 'admin.users.actions.unverify_user', defaultMessage: 'Unverify @{name}' }, + setDonor: { id: 'admin.users.actions.set_donor', defaultMessage: 'Set @{name} as a donor' }, + removeDonor: { id: 'admin.users.actions.remove_donor', defaultMessage: 'Remove @{name} as a donor' }, + promoteToAdmin: { id: 'admin.users.actions.promote_to_admin', defaultMessage: 'Promote @{name} to an admin' }, + promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' }, + demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' }, + demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' }, + suggestUser: { id: 'admin.users.actions.suggest_user', defaultMessage: 'Suggest @{name}' }, + unsuggestUser: { id: 'admin.users.actions.unsuggest_user', defaultMessage: 'Unsuggest @{name}' }, + search: { id: 'account.search', defaultMessage: 'Search from @{name}' }, + unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, + blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, + blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, + userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' }, + userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' }, + setDonorSuccess: { id: 'admin.users.set_donor_message', defaultMessage: '@{acct} was set as a donor' }, + removeDonorSuccess: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed as a donor' }, + promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' }, + promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' }, + demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' }, + demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' }, + userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' }, + userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' }, + removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' }, + userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' }, + userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' }, + +}); + +interface IHeader { + account?: Account, +} + +const Header: React.FC = ({ account }) => { + const intl = useIntl(); + const history = useHistory(); + const dispatch = useAppDispatch(); + + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + if (!account) { + return ( +
+
+
+
+ +
+
+
+
+
+
+
+
+ ); + } + + const onBlock = () => { + if (account.relationship?.blocking) { + dispatch(unblockAccount(account.id)); + } else { + 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)); + }, + })); + } + }; + + const onMention = () => { + dispatch(mentionCompose(account)); + }; + + const onDirect = () => { + dispatch(directCompose(account)); + }; + + const onReblogToggle = () => { + if (account.relationship?.showing_reblogs) { + dispatch(followAccount(account.id, { reblogs: false })); + } else { + dispatch(followAccount(account.id, { reblogs: true })); + } + }; + + const onEndorseToggle = () => { + if (account.relationship?.endorsed) { + dispatch(unpinAccount(account.id)) + .then(() => dispatch(snackbar.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct })))) + .catch(() => {}); + } else { + dispatch(pinAccount(account.id)) + .then(() => dispatch(snackbar.success(intl.formatMessage(messages.userEndorsed, { acct: account.acct })))) + .catch(() => {}); + } + }; + + const onReport = () => { + dispatch(initReport(account)); + }; + + const onMute = () => { + if (account.relationship?.muting) { + dispatch(unmuteAccount(account.id)); + } else { + dispatch(initMuteModal(account)); + } + }; + + const onBlockDomain = (domain: string) => { + dispatch(openModal('CONFIRM', { + icon: require('@tabler/icons/ban.svg'), + heading: , + message: {domain}
}} />, + confirm: intl.formatMessage(messages.blockDomainConfirm), + onConfirm: () => dispatch(blockDomain(domain)), + })); + }; + + const onUnblockDomain = (domain: string) => { + dispatch(unblockDomain(domain)); + }; + + const onAddToList = () => { + dispatch(openModal('LIST_ADDER', { + accountId: account.id, + })); + }; + + const onChat = () => { + dispatch(launchChat(account.id, history)); + }; + + const onDeactivateUser = () => { + dispatch(deactivateUserModal(intl, account.id)); + }; + + const onVerifyUser = () => { + const message = intl.formatMessage(messages.userVerified, { acct: account.acct }); + + dispatch(verifyUser(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onUnverifyUser = () => { + const message = intl.formatMessage(messages.userUnverified, { acct: account.acct }); + + dispatch(unverifyUser(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onSetDonor = () => { + const message = intl.formatMessage(messages.setDonorSuccess, { acct: account.acct }); + + dispatch(setDonor(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onRemoveDonor = () => { + const message = intl.formatMessage(messages.removeDonorSuccess, { acct: account.acct }); + + dispatch(removeDonor(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onPromoteToAdmin = () => { + const message = intl.formatMessage(messages.promotedToAdmin, { acct: account.acct }); + + dispatch(promoteToAdmin(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onPromoteToModerator = () => { + const messageType = account.admin ? messages.demotedToModerator : messages.promotedToModerator; + const message = intl.formatMessage(messageType, { acct: account.acct }); + + dispatch(promoteToModerator(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onDemoteToUser = () => { + const message = intl.formatMessage(messages.demotedToUser, { acct: account.acct }); + + dispatch(demoteToUser(account.id)) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onSuggestUser = () => { + const message = intl.formatMessage(messages.userSuggested, { acct: account.acct }); + + dispatch(suggestUsers([account.id])) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onUnsuggestUser = () => { + const message = intl.formatMessage(messages.userUnsuggested, { acct: account.acct }); + + dispatch(unsuggestUsers([account.id])) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }; + + const onRemoveFromFollowers = () => { + dispatch((_, getState) => { + const unfollowModal = getSettings(getState()).get('unfollowModal'); + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.acct} }} />, + confirm: intl.formatMessage(messages.removeFromFollowersConfirm), + onConfirm: () => dispatch(removeFromFollowers(account.id)), + })); + } else { + dispatch(removeFromFollowers(account.id)); + } + }); + }; + + const onSearch = () => { + dispatch(setSearchAccount(account.id)); + history.push('/search'); + }; + + const onAvatarClick = () => { + const avatar_url = account.avatar; + const avatar = ImmutableMap({ + type: 'image', + preview_url: avatar_url, + url: avatar_url, + description: '', + }); + dispatch(openModal('MEDIA', { media: ImmutableList.of(avatar), index: 0 })); + }; + + const handleAvatarClick: React.MouseEventHandler = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + onAvatarClick(); + } + }; + + const onHeaderClick = () => { + const header_url = account.header; + const header = ImmutableMap({ + type: 'image', + preview_url: header_url, + url: header_url, + description: '', + }); + dispatch(openModal('MEDIA', { media: ImmutableList.of(header), index: 0 })); + }; + + const handleHeaderClick: React.MouseEventHandler = (e) => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + onHeaderClick(); + } + }; + + const handleShare = () => { + navigator.share({ + text: `@${account.acct}`, + url: account.url, + }).catch((e) => { + if (e.name !== 'AbortError') console.error(e); + }); + }; + + const makeMenu = () => { + const menu: MenuType = []; + + if (!account || !ownAccount) { + return []; + } + + if ('share' in navigator) { + menu.push({ + text: intl.formatMessage(messages.share, { name: account.username }), + action: handleShare, + icon: require('@tabler/icons/upload.svg'), + }); + menu.push(null); + } + + if (account.id === ownAccount?.id) { + menu.push({ + text: intl.formatMessage(messages.edit_profile), + to: '/settings/profile', + icon: require('@tabler/icons/user.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.preferences), + to: '/settings', + icon: require('@tabler/icons/settings.svg'), + }); + menu.push(null); + menu.push({ + text: intl.formatMessage(messages.mutes), + to: '/mutes', + icon: require('@tabler/icons/circle-x.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.blocks), + to: '/blocks', + icon: require('@tabler/icons/ban.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.mention, { name: account.username }), + action: onMention, + icon: require('@tabler/icons/at.svg'), + }); + + if (account.getIn(['pleroma', 'accepts_chat_messages']) === true) { + menu.push({ + text: intl.formatMessage(messages.chat, { name: account.username }), + action: onChat, + icon: require('@tabler/icons/messages.svg'), + }); + } else if (features.privacyScopes) { + menu.push({ + text: intl.formatMessage(messages.direct, { name: account.username }), + action: onDirect, + icon: require('@tabler/icons/mail.svg'), + }); + } + + if (account.relationship?.following) { + if (account.relationship?.showing_reblogs) { + menu.push({ + text: intl.formatMessage(messages.hideReblogs, { name: account.username }), + action: onReblogToggle, + icon: require('@tabler/icons/repeat.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.showReblogs, { name: account.username }), + action: onReblogToggle, + icon: require('@tabler/icons/repeat.svg'), + }); + } + + if (features.lists) { + menu.push({ + text: intl.formatMessage(messages.add_or_remove_from_list), + action: onAddToList, + icon: require('@tabler/icons/list.svg'), + }); + } + + if (features.accountEndorsements) { + menu.push({ + text: intl.formatMessage(account.relationship?.endorsed ? messages.unendorse : messages.endorse), + action: onEndorseToggle, + icon: require('@tabler/icons/user-check.svg'), + }); + } + + menu.push(null); + } else if (features.lists && features.unrestrictedLists) { + menu.push({ + text: intl.formatMessage(messages.add_or_remove_from_list), + action: onAddToList, + icon: require('@tabler/icons/list.svg'), + }); + } + + if (features.searchFromAccount) { + menu.push({ + text: intl.formatMessage(messages.search, { name: account.username }), + action: onSearch, + icon: require('@tabler/icons/search.svg'), + }); + } + + if (features.removeFromFollowers && account.relationship?.followed_by) { + menu.push({ + text: intl.formatMessage(messages.removeFromFollowers), + action: onRemoveFromFollowers, + icon: require('@tabler/icons/user-x.svg'), + }); + } + + if (account.relationship?.muting) { + menu.push({ + text: intl.formatMessage(messages.unmute, { name: account.username }), + action: onMute, + icon: require('@tabler/icons/circle-x.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.mute, { name: account.username }), + action: onMute, + icon: require('@tabler/icons/circle-x.svg'), + }); + } + + if (account.relationship?.blocking) { + menu.push({ + text: intl.formatMessage(messages.unblock, { name: account.username }), + action: onBlock, + icon: require('@tabler/icons/ban.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.block, { name: account.username }), + action: onBlock, + icon: require('@tabler/icons/ban.svg'), + }); + } + + menu.push({ + text: intl.formatMessage(messages.report, { name: account.username }), + action: onReport, + icon: require('@tabler/icons/flag.svg'), + }); + } + + if (isRemote(account)) { + const domain = account.fqn.split('@')[1]; + + menu.push(null); + + if (account.relationship?.domain_blocking) { + menu.push({ + text: intl.formatMessage(messages.unblockDomain, { domain }), + action: () => onUnblockDomain(domain), + icon: require('@tabler/icons/ban.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.blockDomain, { domain }), + action: () => onBlockDomain(domain), + icon: require('@tabler/icons/ban.svg'), + }); + } + } + + if (ownAccount?.staff) { + menu.push(null); + + if (ownAccount?.admin) { + menu.push({ + text: intl.formatMessage(messages.admin_account, { name: account.username }), + to: `/pleroma/admin/#/users/${account.id}/`, + newTab: true, + icon: require('@tabler/icons/gavel.svg'), + }); + } + + if (account.id !== ownAccount?.id && isLocal(account) && ownAccount.admin) { + if (account.admin) { + menu.push({ + text: intl.formatMessage(messages.demoteToModerator, { name: account.username }), + action: onPromoteToModerator, + icon: require('@tabler/icons/arrow-up-circle.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.demoteToUser, { name: account.username }), + action: onDemoteToUser, + icon: require('@tabler/icons/arrow-down-circle.svg'), + }); + } else if (account.moderator) { + menu.push({ + text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }), + action: onPromoteToAdmin, + icon: require('@tabler/icons/arrow-up-circle.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.demoteToUser, { name: account.username }), + action: onDemoteToUser, + icon: require('@tabler/icons/arrow-down-circle.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.promoteToAdmin, { name: account.username }), + action: onPromoteToAdmin, + icon: require('@tabler/icons/arrow-up-circle.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.promoteToModerator, { name: account.username }), + action: onPromoteToModerator, + icon: require('@tabler/icons/arrow-up-circle.svg'), + }); + } + } + + if (account.verified) { + menu.push({ + text: intl.formatMessage(messages.unverifyUser, { name: account.username }), + action: onUnverifyUser, + icon: require('@tabler/icons/check.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.verifyUser, { name: account.username }), + action: onVerifyUser, + icon: require('@tabler/icons/check.svg'), + }); + } + + if (account.donor) { + menu.push({ + text: intl.formatMessage(messages.removeDonor, { name: account.username }), + action: onRemoveDonor, + icon: require('@tabler/icons/coin.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.setDonor, { name: account.username }), + action: onSetDonor, + icon: require('@tabler/icons/coin.svg'), + }); + } + + if (features.suggestionsV2 && ownAccount.admin) { + if (account.getIn(['pleroma', 'is_suggested'])) { + menu.push({ + text: intl.formatMessage(messages.unsuggestUser, { name: account.username }), + action: onUnsuggestUser, + icon: require('@tabler/icons/user-x.svg'), + }); + } else { + menu.push({ + text: intl.formatMessage(messages.suggestUser, { name: account.username }), + action: onSuggestUser, + icon: require('@tabler/icons/user-check.svg'), + }); + } + } + + if (account.id !== ownAccount?.id) { + menu.push({ + text: intl.formatMessage(messages.deactivateUser, { name: account.username }), + action: onDeactivateUser, + icon: require('@tabler/icons/user-off.svg'), + }); + menu.push({ + text: intl.formatMessage(messages.deleteUser, { name: account.username }), + icon: require('@tabler/icons/user-minus.svg'), + }); + } + } + + return menu; + }; + + const makeInfo = () => { + const info: React.ReactNode[] = []; + + if (!account || !ownAccount) return info; + + if (ownAccount?.id !== account.id && account.relationship?.followed_by) { + info.push( + } + />, + ); + } else if (ownAccount?.id !== account.id && account.relationship?.blocking) { + info.push( + } + />, + ); + } + + if (ownAccount?.id !== account.id && account.relationship?.muting) { + info.push( + } + />, + ); + } else if (ownAccount?.id !== account.id && account.relationship?.domain_blocking) { + info.push( + } + />, + ); + } + + return info; + }; + + // const renderMessageButton = () => { + // if (!ownAccount || !account || account.id === ownAccount?.id) { + // return null; + // } + + // const canChat = account.getIn(['pleroma', 'accepts_chat_messages']) === true; + + // if (canChat) { + // return ( + // + // ); + // } else { + // return ( + // + // ); + // } + // }; + + const renderShareButton = () => { + const canShare = 'share' in navigator; + + if (!(account && ownAccount?.id && account.id === ownAccount?.id && canShare)) { + return null; + } + + return ( + + ); + }; + + const info = makeInfo(); + const menu = makeMenu(); + + return ( +
+ {(account.moved && typeof account.moved === 'object') && ( + + )} + +
+
+ {account.header && ( + + + + )} + +
+ + {info} + +
+
+
+ +
+
+
+ + + +
+ +
+
+ + + {ownAccount && ( + + + + + {menu.map((menuItem, idx) => { + if (typeof menuItem?.text === 'undefined') { + return ; + } else { + const Comp = (menuItem.action ? MenuItem : MenuLink) as any; + const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; + + return ( + +
+ {menuItem.icon && ( + + )} + +
{menuItem.text}
+
+
+ ); + } + })} +
+
+ )} + + {renderShareButton()} + {/* {renderMessageButton()} */} + + +
+
+
+
+
+ ); +}; + +export default Header; diff --git a/app/soapbox/features/account_gallery/components/media_item.js b/app/soapbox/features/account_gallery/components/media_item.js deleted file mode 100644 index fe8c6cc85..000000000 --- a/app/soapbox/features/account_gallery/components/media_item.js +++ /dev/null @@ -1,158 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { connect } from 'react-redux'; - -import { getSettings } from 'soapbox/actions/settings'; -import Blurhash from 'soapbox/components/blurhash'; -import Icon from 'soapbox/components/icon'; -import StillImage from 'soapbox/components/still_image'; -import { isIOS } from 'soapbox/is_mobile'; - -const mapStateToProps = state => ({ - autoPlayGif: getSettings(state).get('autoPlayGif'), - displayMedia: getSettings(state).get('displayMedia'), -}); - -export default @connect(mapStateToProps) -class MediaItem extends ImmutablePureComponent { - - static propTypes = { - attachment: ImmutablePropTypes.map.isRequired, - displayWidth: PropTypes.number.isRequired, - onOpenMedia: PropTypes.func.isRequired, - autoPlayGif: PropTypes.bool, - displayMedia: PropTypes.string, - }; - - state = { - visible: this.props.displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || this.props.displayMedia === 'show_all', - loaded: false, - }; - - handleImageLoad = () => { - this.setState({ loaded: true }); - } - - handleMouseEnter = e => { - if (this.hoverToPlay()) { - e.target.play(); - } - } - - handleMouseLeave = e => { - if (this.hoverToPlay()) { - e.target.pause(); - e.target.currentTime = 0; - } - } - - hoverToPlay = () => { - const { autoPlayGif } = this.props; - return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1; - } - - handleClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - - if (this.state.visible) { - this.props.onOpenMedia(this.props.attachment); - } else { - this.setState({ visible: true }); - } - } - } - - render() { - const { attachment, displayWidth, autoPlayGif } = this.props; - const { visible, loaded } = this.state; - - const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; - const height = width; - const status = attachment.get('status'); - const title = status.get('spoiler_text') || attachment.get('description'); - - let thumbnail = ''; - let icon; - - if (attachment.get('type') === 'unknown') { - // Skip - } else if (attachment.get('type') === 'image') { - const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0; - const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0; - const x = ((focusX / 2) + .5) * 100; - const y = ((focusY / -2) + .5) * 100; - - thumbnail = ( - - ); - } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) { - const conditionalAttributes = {}; - if (isIOS()) { - conditionalAttributes.playsInline = '1'; - } - if (autoPlayGif) { - conditionalAttributes.autoPlay = '1'; - } - thumbnail = ( -
-
- ); - } else if (attachment.get('type') === 'audio') { - const remoteURL = attachment.get('remote_url') || ''; - const fileExtensionLastIndex = remoteURL.lastIndexOf('.'); - const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase(); - thumbnail = ( -
- - {fileExtension} -
- ); - } - - if (!visible) { - icon = ( - - - - ); - } - - return ( - - ); - } - -} diff --git a/app/soapbox/features/account_gallery/components/media_item.tsx b/app/soapbox/features/account_gallery/components/media_item.tsx new file mode 100644 index 000000000..c113ac5e6 --- /dev/null +++ b/app/soapbox/features/account_gallery/components/media_item.tsx @@ -0,0 +1,141 @@ +import classNames from 'classnames'; +import React, { useState } from 'react'; + +import Blurhash from 'soapbox/components/blurhash'; +import Icon from 'soapbox/components/icon'; +import StillImage from 'soapbox/components/still_image'; +import { useSettings } from 'soapbox/hooks'; +import { isIOS } from 'soapbox/is_mobile'; + +import type { Attachment } from 'soapbox/types/entities'; + +interface IMediaItem { + attachment: Attachment, + displayWidth: number, + onOpenMedia: (attachment: Attachment) => void, +} + +const MediaItem: React.FC = ({ attachment, displayWidth, onOpenMedia }) => { + const settings = useSettings(); + const autoPlayGif = settings.get('autoPlayGif'); + const displayMedia = settings.get('displayMedia'); + + const [visible, setVisible] = useState(displayMedia !== 'hide_all' && !attachment.status?.sensitive || displayMedia === 'show_all'); + + const handleMouseEnter: React.MouseEventHandler = e => { + const video = e.target as HTMLVideoElement; + if (hoverToPlay()) { + video.play(); + } + }; + + const handleMouseLeave: React.MouseEventHandler = e => { + const video = e.target as HTMLVideoElement; + if (hoverToPlay()) { + video.pause(); + video.currentTime = 0; + } + }; + + const hoverToPlay = () => { + return !autoPlayGif && ['gifv', 'video'].indexOf(attachment.type) !== -1; + }; + + const handleClick: React.MouseEventHandler = e => { + if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { + e.preventDefault(); + + if (visible) { + onOpenMedia(attachment); + } else { + setVisible(true); + } + } + }; + + const width = `${Math.floor((displayWidth - 4) / 3) - 4}px`; + const height = width; + const status = attachment.get('status'); + const title = status.get('spoiler_text') || attachment.get('description'); + + let thumbnail: React.ReactNode = ''; + let icon; + + if (attachment.type === 'unknown') { + // Skip + } else if (attachment.type === 'image') { + const focusX = Number(attachment.getIn(['meta', 'focus', 'x'])) || 0; + const focusY = Number(attachment.getIn(['meta', 'focus', 'y'])) || 0; + const x = ((focusX / 2) + .5) * 100; + const y = ((focusY / -2) + .5) * 100; + + thumbnail = ( + + ); + } else if (['gifv', 'video'].indexOf(attachment.type) !== -1) { + const conditionalAttributes: React.VideoHTMLAttributes = {}; + if (isIOS()) { + conditionalAttributes.playsInline = true; + } + if (autoPlayGif) { + conditionalAttributes.autoPlay = true; + } + thumbnail = ( +
+
+ ); + } else if (attachment.type === 'audio') { + const remoteURL = attachment.remote_url || ''; + const fileExtensionLastIndex = remoteURL.lastIndexOf('.'); + const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase(); + thumbnail = ( +
+ + {fileExtension} +
+ ); + } + + if (!visible) { + icon = ( + + + + ); + } + + return ( + + ); +}; + +export default MediaItem; diff --git a/app/soapbox/features/account_timeline/components/column_settings.js b/app/soapbox/features/account_timeline/components/column_settings.js deleted file mode 100644 index 236fb4583..000000000 --- a/app/soapbox/features/account_timeline/components/column_settings.js +++ /dev/null @@ -1,64 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import IconButton from 'soapbox/components/icon_button'; - -import SettingToggle from '../../notifications/components/setting_toggle'; - -const messages = defineMessages({ - close: { id: 'lightbox.close', defaultMessage: 'Close' }, -}); - -export default @injectIntl -class ColumnSettings extends React.PureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - settings: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - }; - - render() { - const { intl, settings, onChange, onClose } = this.props; - - return ( -
-
-

- -

-
- -
-
- -
-
- -
- -
- } - /> - } - /> -
-
-
- ); - } - -} diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js deleted file mode 100644 index 493bd7a39..000000000 --- a/app/soapbox/features/account_timeline/components/header.js +++ /dev/null @@ -1,198 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { withRouter } from 'react-router-dom'; - -import InnerHeader from '../../account/components/header'; - -import MovedNote from './moved_note'; - -export default @withRouter -class Header extends ImmutablePureComponent { - - static propTypes = { - account: ImmutablePropTypes.record, - onFollow: PropTypes.func.isRequired, - onBlock: PropTypes.func.isRequired, - onMention: PropTypes.func.isRequired, - onDirect: PropTypes.func.isRequired, - onChat: PropTypes.func, - onReblogToggle: PropTypes.func.isRequired, - onReport: PropTypes.func.isRequired, - onMute: PropTypes.func.isRequired, - onBlockDomain: PropTypes.func.isRequired, - onUnblockDomain: PropTypes.func.isRequired, - onEndorseToggle: PropTypes.func.isRequired, - onAddToList: PropTypes.func.isRequired, - onRemoveFromFollowers: PropTypes.func.isRequired, - onSearch: PropTypes.func.isRequired, - username: PropTypes.string, - history: PropTypes.object, - }; - - handleFollow = () => { - this.props.onFollow(this.props.account); - } - - handleBlock = () => { - this.props.onBlock(this.props.account); - } - - handleMention = () => { - this.props.onMention(this.props.account); - } - - handleDirect = () => { - this.props.onDirect(this.props.account); - } - - handleReport = () => { - this.props.onReport(this.props.account); - } - - handleReblogToggle = () => { - this.props.onReblogToggle(this.props.account); - } - - handleSubscriptionToggle = () => { - this.props.onSubscriptionToggle(this.props.account); - } - - handleNotifyToggle = () => { - this.props.onNotifyToggle(this.props.account); - } - - handleMute = () => { - this.props.onMute(this.props.account); - } - - handleBlockDomain = () => { - const domain = this.props.account.get('acct').split('@')[1]; - - if (!domain) return; - - this.props.onBlockDomain(domain); - } - - handleUnblockDomain = () => { - const domain = this.props.account.get('acct').split('@')[1]; - - if (!domain) return; - - this.props.onUnblockDomain(domain); - } - - handleChat = () => { - this.props.onChat(this.props.account, this.props.history); - } - - handleEndorseToggle = () => { - this.props.onEndorseToggle(this.props.account); - } - - handleAddToList = () => { - this.props.onAddToList(this.props.account); - } - - handleDeactivateUser = () => { - this.props.onDeactivateUser(this.props.account); - } - - handleDeleteUser = () => { - this.props.onDeleteUser(this.props.account); - } - - handleVerifyUser = () => { - this.props.onVerifyUser(this.props.account); - } - - handleUnverifyUser = () => { - this.props.onUnverifyUser(this.props.account); - } - - handleSetDonor = () => { - this.props.onSetDonor(this.props.account); - } - - handleRemoveDonor = () => { - this.props.onRemoveDonor(this.props.account); - } - - handlePromoteToAdmin = () => { - this.props.onPromoteToAdmin(this.props.account); - } - - handlePromoteToModerator = () => { - this.props.onPromoteToModerator(this.props.account); - } - - handleDemoteToUser = () => { - this.props.onDemoteToUser(this.props.account); - } - - handleSuggestUser = () => { - this.props.onSuggestUser(this.props.account); - } - - handleUnsuggestUser = () => { - this.props.onUnsuggestUser(this.props.account); - } - - handleShowNote = () => { - this.props.onShowNote(this.props.account); - } - - handleRemoveFromFollowers = () => { - this.props.onRemoveFromFollowers(this.props.account); - } - - handleSearch = () => { - this.props.onSearch(this.props.account, this.props.history); - } - - render() { - const { account } = this.props; - const moved = (account) ? account.get('moved') : false; - - return ( - <> - { moved && } - - - - ); - } - -} diff --git a/app/soapbox/features/account_timeline/containers/column_settings_container.js b/app/soapbox/features/account_timeline/containers/column_settings_container.js deleted file mode 100644 index c0b9fdf35..000000000 --- a/app/soapbox/features/account_timeline/containers/column_settings_container.js +++ /dev/null @@ -1,18 +0,0 @@ -import { connect } from 'react-redux'; - -import { getSettings, changeSetting } from '../../../actions/settings'; -import ColumnSettings from '../components/column_settings'; - -const mapStateToProps = state => ({ - settings: getSettings(state).get('account_timeline'), -}); - -const mapDispatchToProps = (dispatch) => { - return { - onChange(key, checked) { - dispatch(changeSetting(['account_timeline', ...key], checked)); - }, - }; -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js deleted file mode 100644 index af113d8f2..000000000 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ /dev/null @@ -1,304 +0,0 @@ -import React from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { initAccountNoteModal } from 'soapbox/actions/account-notes'; -import { - followAccount, - unfollowAccount, - blockAccount, - unblockAccount, - unmuteAccount, - pinAccount, - unpinAccount, - subscribeAccount, - unsubscribeAccount, - removeFromFollowers, -} from 'soapbox/actions/accounts'; -import { - verifyUser, - unverifyUser, - setDonor, - removeDonor, - promoteToAdmin, - promoteToModerator, - demoteToUser, - suggestUsers, - unsuggestUsers, -} from 'soapbox/actions/admin'; -import { launchChat } from 'soapbox/actions/chats'; -import { - mentionCompose, - directCompose, -} from 'soapbox/actions/compose'; -import { blockDomain, unblockDomain } from 'soapbox/actions/domain_blocks'; -import { openModal } from 'soapbox/actions/modals'; -import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; -import { initMuteModal } from 'soapbox/actions/mutes'; -import { initReport } from 'soapbox/actions/reports'; -import { setSearchAccount } from 'soapbox/actions/search'; -import { getSettings } from 'soapbox/actions/settings'; -import snackbar from 'soapbox/actions/snackbar'; -import { makeGetAccount } from 'soapbox/selectors'; - -import Header from '../components/header'; - -const messages = defineMessages({ - unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, - blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' }, - blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, - blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, - userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' }, - userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' }, - setDonor: { id: 'admin.users.set_donor_message', defaultMessage: '@{acct} was set as a donor' }, - removeDonor: { id: 'admin.users.remove_donor_message', defaultMessage: '@{acct} was removed as a donor' }, - promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' }, - promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' }, - demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' }, - demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' }, - userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' }, - userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' }, - removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' }, - userEndorsed: { id: 'account.endorse.success', defaultMessage: 'You are now featuring @{acct} on your profile' }, - userUnendorsed: { id: 'account.unendorse.success', defaultMessage: 'You are no longer featuring @{acct}' }, -}); - -const makeMapStateToProps = () => { - const getAccount = makeGetAccount(); - - const mapStateToProps = (state, { accountId }) => ({ - account: getAccount(state, accountId), - }); - - return mapStateToProps; -}; - -const mapDispatchToProps = (dispatch, { intl }) => ({ - - onFollow(account) { - dispatch((_, getState) => { - const unfollowModal = getSettings(getState()).get('unfollowModal'); - if (account.relationship?.following || account.relationship?.requested) { - if (unfollowModal) { - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.unfollowConfirm), - onConfirm: () => dispatch(unfollowAccount(account.get('id'))), - })); - } else { - dispatch(unfollowAccount(account.get('id'))); - } - } else { - dispatch(followAccount(account.get('id'))); - } - }); - }, - - onBlock(account) { - if (account.relationship?.blocking) { - dispatch(unblockAccount(account.get('id'))); - } else { - 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)); - }, - })); - } - }, - - onMention(account) { - dispatch(mentionCompose(account)); - }, - - onDirect(account) { - dispatch(directCompose(account)); - }, - - onReblogToggle(account) { - if (account.relationship?.showing_reblogs) { - dispatch(followAccount(account.get('id'), { reblogs: false })); - } else { - dispatch(followAccount(account.get('id'), { reblogs: true })); - } - }, - - onSubscriptionToggle(account) { - if (account.relationship?.subscribing) { - dispatch(unsubscribeAccount(account.get('id'))); - } else { - dispatch(subscribeAccount(account.get('id'))); - } - }, - - onNotifyToggle(account) { - if (account.relationship?.notifying) { - dispatch(followAccount(account.get('id'), { notify: false })); - } else { - dispatch(followAccount(account.get('id'), { notify: true })); - } - }, - - onEndorseToggle(account) { - if (account.relationship?.endorsed) { - dispatch(unpinAccount(account.get('id'))) - .then(() => dispatch(snackbar.success(intl.formatMessage(messages.userUnendorsed, { acct: account.acct })))) - .catch(() => {}); - } else { - dispatch(pinAccount(account.get('id'))) - .then(() => dispatch(snackbar.success(intl.formatMessage(messages.userEndorsed, { acct: account.acct })))) - .catch(() => {}); - } - }, - - onReport(account) { - dispatch(initReport(account)); - }, - - onMute(account) { - if (account.relationship?.muting) { - dispatch(unmuteAccount(account.get('id'))); - } else { - dispatch(initMuteModal(account)); - } - }, - - onBlockDomain(domain) { - dispatch(openModal('CONFIRM', { - icon: require('@tabler/icons/ban.svg'), - heading: , - message: {domain} }} />, - confirm: intl.formatMessage(messages.blockDomainConfirm), - onConfirm: () => dispatch(blockDomain(domain)), - })); - }, - - onUnblockDomain(domain) { - dispatch(unblockDomain(domain)); - }, - - onAddToList(account) { - dispatch(openModal('LIST_ADDER', { - accountId: account.get('id'), - })); - }, - - onChat(account, router) { - dispatch(launchChat(account.get('id'), router)); - }, - - onDeactivateUser(account) { - dispatch(deactivateUserModal(intl, account.get('id'))); - }, - - onDeleteUser(account) { - dispatch(deleteUserModal(intl, account.get('id'))); - }, - - onVerifyUser(account) { - const message = intl.formatMessage(messages.userVerified, { acct: account.get('acct') }); - - dispatch(verifyUser(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onUnverifyUser(account) { - const message = intl.formatMessage(messages.userUnverified, { acct: account.get('acct') }); - - dispatch(unverifyUser(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onSetDonor(account) { - const message = intl.formatMessage(messages.setDonor, { acct: account.get('acct') }); - - dispatch(setDonor(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onRemoveDonor(account) { - const message = intl.formatMessage(messages.removeDonor, { acct: account.get('acct') }); - - dispatch(removeDonor(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onPromoteToAdmin(account) { - const message = intl.formatMessage(messages.promotedToAdmin, { acct: account.get('acct') }); - - dispatch(promoteToAdmin(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onPromoteToModerator(account) { - const messageType = account.admin ? messages.demotedToModerator : messages.promotedToModerator; - const message = intl.formatMessage(messageType, { acct: account.get('acct') }); - - dispatch(promoteToModerator(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onDemoteToUser(account) { - const message = intl.formatMessage(messages.demotedToUser, { acct: account.get('acct') }); - - dispatch(demoteToUser(account.get('id'))) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onSuggestUser(account) { - const message = intl.formatMessage(messages.userSuggested, { acct: account.get('acct') }); - - dispatch(suggestUsers([account.get('id')])) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onUnsuggestUser(account) { - const message = intl.formatMessage(messages.userUnsuggested, { acct: account.get('acct') }); - - dispatch(unsuggestUsers([account.get('id')])) - .then(() => dispatch(snackbar.success(message))) - .catch(() => {}); - }, - - onShowNote(account) { - dispatch(initAccountNoteModal(account)); - }, - - onRemoveFromFollowers(account) { - dispatch((_, getState) => { - const unfollowModal = getSettings(getState()).get('unfollowModal'); - if (unfollowModal) { - dispatch(openModal('CONFIRM', { - message: @{account.get('acct')} }} />, - confirm: intl.formatMessage(messages.removeFromFollowersConfirm), - onConfirm: () => dispatch(removeFromFollowers(account.get('id'))), - })); - } else { - dispatch(removeFromFollowers(account.get('id'))); - } - }); - }, - - onSearch(account, router) { - dispatch((dispatch) => { - dispatch(setSearchAccount(account.id)); - router.push('/search'); - }); - }, -}); - -export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/soapbox/features/account_timeline/index.js b/app/soapbox/features/account_timeline/index.js deleted file mode 100644 index 907c54d68..000000000 --- a/app/soapbox/features/account_timeline/index.js +++ /dev/null @@ -1,174 +0,0 @@ -import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router-dom'; - -import { fetchAccountByUsername } from 'soapbox/actions/accounts'; -import { fetchPatronAccount } from 'soapbox/actions/patron'; -import { getSettings } from 'soapbox/actions/settings'; -import { getSoapboxConfig } from 'soapbox/actions/soapbox'; -import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'soapbox/actions/timelines'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import StatusList from 'soapbox/components/status_list'; -import { Card, CardBody, Spinner, Text } from 'soapbox/components/ui'; -import { makeGetStatusIds, findAccountByUsername } from 'soapbox/selectors'; -import { getFeatures } from 'soapbox/utils/features'; - -const makeMapStateToProps = () => { - const getStatusIds = makeGetStatusIds(); - - const mapStateToProps = (state, { params, withReplies = false }) => { - const username = params.username || ''; - const me = state.get('me'); - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - const soapboxConfig = getSoapboxConfig(state); - const features = getFeatures(state.get('instance')); - - let accountId = -1; - let account = null; - let accountUsername = username; - let accountApId = null; - if (accountFetchError) { - accountId = null; - } else { - account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - accountUsername = account ? account.getIn(['acct'], '') : ''; - accountApId = account ? account.get('url') : ''; - } - - const path = withReplies ? `${accountId}:with_replies` : accountId; - - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - const showPins = getSettings(state).getIn(['account_timeline', 'shows', 'pinned']) && !withReplies; - - return { - accountId, - unavailable, - accountUsername, - accountApId, - isBlocked, - account, - isAccount: !!state.getIn(['accounts', accountId]), - statusIds: getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' }), - featuredStatusIds: showPins ? getStatusIds(state, { type: `account:${accountId}:pinned`, prefix: 'account_timeline' }) : ImmutableOrderedSet(), - isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), - hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), - me, - patronEnabled: soapboxConfig.getIn(['extensions', 'patron', 'enabled']), - }; - }; - - return mapStateToProps; -}; - -export default @connect(makeMapStateToProps) -@withRouter -class AccountTimeline extends ImmutablePureComponent { - - static propTypes = { - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.orderedSet, - featuredStatusIds: ImmutablePropTypes.orderedSet, - isLoading: PropTypes.bool, - hasMore: PropTypes.bool, - withReplies: PropTypes.bool, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - }; - - componentDidMount() { - const { params: { username }, accountId, accountApId, withReplies, patronEnabled, history } = this.props; - - this.props.dispatch(fetchAccountByUsername(username, history)); - - if (accountId && accountId !== -1) { - if (!withReplies) { - this.props.dispatch(expandAccountFeaturedTimeline(accountId)); - } - - if (patronEnabled && accountApId) { - this.props.dispatch(fetchPatronAccount(accountApId)); - } - - this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); - } - } - - componentDidUpdate(prevProps) { - const { params: { username }, accountId, withReplies, accountApId, patronEnabled, history } = this.props; - - if (username && (username !== prevProps.params.username)) { - this.props.dispatch(fetchAccountByUsername(username, history)); - } - - if (accountId && (accountId !== -1) && (accountId !== prevProps.accountId) || withReplies !== prevProps.withReplies) { - if (!withReplies) { - this.props.dispatch(expandAccountFeaturedTimeline(accountId)); - } - - if (patronEnabled && accountApId) { - this.props.dispatch(fetchPatronAccount(accountApId)); - } - - this.props.dispatch(expandAccountTimeline(accountId, { withReplies })); - } - } - - handleLoadMore = maxId => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandAccountTimeline(this.props.accountId, { maxId, withReplies: this.props.withReplies })); - } - } - - render() { - const { statusIds, featuredStatusIds, isLoading, hasMore, isBlocked, isAccount, accountId, unavailable, accountUsername } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!statusIds && isLoading)) { - return ( - - ); - } - - if (unavailable) { - return ( - - - - {isBlocked ? ( - - ) : ( - - )} - - - - ); - } - - return ( - } - /> - ); - } - -} diff --git a/app/soapbox/features/account_timeline/index.tsx b/app/soapbox/features/account_timeline/index.tsx new file mode 100644 index 000000000..08ab71539 --- /dev/null +++ b/app/soapbox/features/account_timeline/index.tsx @@ -0,0 +1,111 @@ +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useHistory } from 'react-router-dom'; + +import { fetchAccountByUsername } from 'soapbox/actions/accounts'; +import { fetchPatronAccount } from 'soapbox/actions/patron'; +import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'soapbox/actions/timelines'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import StatusList from 'soapbox/components/status_list'; +import { Card, CardBody, Spinner, Text } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useFeatures, useSettings, useSoapboxConfig } from 'soapbox/hooks'; +import { makeGetStatusIds, findAccountByUsername } from 'soapbox/selectors'; + +const getStatusIds = makeGetStatusIds(); + +interface IAccountTimeline { + params: { + username: string, + }, + withReplies?: boolean, +} + +const AccountTimeline: React.FC = ({ params, withReplies = false }) => { + const history = useHistory(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const settings = useSettings(); + const soapboxConfig = useSoapboxConfig(); + + const account = useAppSelector(state => findAccountByUsername(state, params.username)); + const [accountLoading, setAccountLoading] = useState(!account); + + const path = withReplies ? `${account?.id}:with_replies` : account?.id; + const showPins = settings.getIn(['account_timeline', 'shows', 'pinned']) === true && !withReplies; + const statusIds = useAppSelector(state => getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' })); + const featuredStatusIds = useAppSelector(state => getStatusIds(state, { type: `account:${account?.id}:pinned`, prefix: 'account_timeline' })); + + const isBlocked = useAppSelector(state => state.relationships.getIn([account?.id, 'blocked_by']) === true); + const unavailable = isBlocked && !features.blockersVisible; + const patronEnabled = soapboxConfig.getIn(['extensions', 'patron', 'enabled']) === true; + const isLoading = useAppSelector(state => state.getIn(['timelines', `account:${path}`, 'isLoading']) === true); + const hasMore = useAppSelector(state => state.getIn(['timelines', `account:${path}`, 'hasMore']) === true); + + const accountUsername = account?.username || params.username; + + useEffect(() => { + dispatch(fetchAccountByUsername(params.username, history)) + .then(() => setAccountLoading(false)) + .catch(() => setAccountLoading(false)); + }, [params.username]); + + useEffect(() => { + if (account && !withReplies) { + dispatch(expandAccountFeaturedTimeline(account.id)); + } + }, [account?.id, withReplies]); + + useEffect(() => { + if (account && patronEnabled) { + dispatch(fetchPatronAccount(account.url)); + } + }, [account?.url, patronEnabled]); + + useEffect(() => { + if (account) { + dispatch(expandAccountTimeline(account.id, { withReplies })); + } + }, [account?.id]); + + const handleLoadMore = (maxId: string) => { + if (account) { + dispatch(expandAccountTimeline(account.id, { maxId, withReplies })); + } + }; + + if (!account && accountLoading) { + return ; + } else if (!account) { + return ; + } + + if (unavailable) { + return ( + + + + {isBlocked ? ( + + ) : ( + + )} + + + + ); + } + + return ( + } + /> + ); +}; + +export default AccountTimeline; 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 /> ; - -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/pages/profile_page.tsx b/app/soapbox/pages/profile_page.tsx index e188b897f..9c5a4a55e 100644 --- a/app/soapbox/pages/profile_page.tsx +++ b/app/soapbox/pages/profile_page.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; import { Redirect, useHistory } from 'react-router-dom'; +import { Column, Layout, Tabs } from 'soapbox/components/ui'; +import Header from 'soapbox/features/account/components/header'; import LinkFooter from 'soapbox/features/ui/components/link_footer'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import { @@ -14,63 +16,35 @@ import { PinnedAccountsPanel, } from 'soapbox/features/ui/util/async-components'; import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks'; -import { findAccountByUsername } from 'soapbox/selectors'; +import { findAccountByUsername, makeGetAccount } from 'soapbox/selectors'; import { getAcct, isLocal } from 'soapbox/utils/accounts'; -import { Column, Layout, Tabs } from '../components/ui'; -import HeaderContainer from '../features/account_timeline/containers/header_container'; -import { makeGetAccount } from '../selectors'; - -const getAccount = makeGetAccount(); - interface IProfilePage { params?: { username?: string, }, } +const getAccount = makeGetAccount(); + /** Page to display a user's profile. */ const ProfilePage: React.FC = ({ params, children }) => { const history = useHistory(); const username = params?.username || ''; - const { accountId, account, realAccount } = useAppSelector(state => { - const { accounts } = state; - const accountFetchError = (((state.accounts.getIn([-1, 'username']) || '') as string).toLowerCase() === username.toLowerCase()); - - let accountId: string | -1 | null = -1; - let account = null; - if (accountFetchError) { - accountId = null; - } else { - account = findAccountByUsername(state, username); - accountId = account ? account.id : -1; - } - - let realAccount; - if (!account) { - const maybeAccount = accounts.get(username); - if (maybeAccount) { - realAccount = maybeAccount; + const account = useAppSelector(state => { + if (username) { + const account = findAccountByUsername(state, username); + if (account) { + return getAccount(state, account.id) || undefined; } } - - return { - account: typeof accountId === 'string' ? getAccount(state, accountId) : account, - accountId, - realAccount, - }; }); const me = useAppSelector(state => state.me); const features = useFeatures(); const { displayFqn } = useSoapboxConfig(); - // Redirect from a user ID - if (realAccount) { - return ; - } - // Fix case of username if (account && account.acct !== username) { return ; @@ -107,25 +81,24 @@ const ProfilePage: React.FC = ({ params, children }) => { let activeItem; const pathname = history.location.pathname.replace(`@${username}/`, ''); - if (pathname.includes('with_replies')) { + if (pathname.endsWith('/with_replies')) { activeItem = 'replies'; - } else if (pathname.includes('media')) { + } else if (pathname.endsWith('/media')) { activeItem = 'media'; - } else if (pathname.includes('favorites')) { + } else if (pathname.endsWith('/favorites')) { activeItem = 'likes'; } else { activeItem = 'profile'; } - const showTabs = !['following', 'followers', 'pins'].some(path => pathname.includes(path)); + const showTabs = !['/following', '/followers', '/pins'].some(path => pathname.endsWith(path)); return ( <>
- {/* @ts-ignore */} - +
{Component => } 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'],