diff --git a/app/soapbox/actions/admin.ts b/app/soapbox/actions/admin.ts index 660b52dce..02af5e87a 100644 --- a/app/soapbox/actions/admin.ts +++ b/app/soapbox/actions/admin.ts @@ -1,5 +1,6 @@ import { fetchRelationships } from 'soapbox/actions/accounts'; import { importFetchedAccount, importFetchedAccounts, importFetchedStatuses } from 'soapbox/actions/importer'; +import { filterBadges, getTagDiff } from 'soapbox/utils/badges'; import { getFeatures } from 'soapbox/utils/features'; import api, { getLinks } from '../api'; @@ -403,6 +404,12 @@ const tagUsers = (accountIds: string[], tags: string[]) => const untagUsers = (accountIds: string[], tags: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); + + // Legacy: allow removing legacy 'donor' tags. + if (tags.includes('badge:donor')) { + tags = [...tags, 'donor']; + } + dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); return api(getState) .delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } }) @@ -413,6 +420,24 @@ const untagUsers = (accountIds: string[], tags: string[]) => }); }; +/** Synchronizes user tags to the backend. */ +const setTags = (accountId: string, oldTags: string[], newTags: string[]) => + async(dispatch: AppDispatch) => { + const diff = getTagDiff(oldTags, newTags); + + await dispatch(tagUsers([accountId], diff.added)); + await dispatch(untagUsers([accountId], diff.removed)); + }; + +/** Synchronizes badges to the backend. */ +const setBadges = (accountId: string, oldTags: string[], newTags: string[]) => + (dispatch: AppDispatch) => { + const oldBadges = filterBadges(oldTags); + const newBadges = filterBadges(newTags); + + return dispatch(setTags(accountId, oldBadges, newBadges)); + }; + const verifyUser = (accountId: string) => (dispatch: AppDispatch) => dispatch(tagUsers([accountId], ['verified'])); @@ -421,14 +446,6 @@ const unverifyUser = (accountId: string) => (dispatch: AppDispatch) => dispatch(untagUsers([accountId], ['verified'])); -const setDonor = (accountId: string) => - (dispatch: AppDispatch) => - dispatch(tagUsers([accountId], ['donor'])); - -const removeDonor = (accountId: string) => - (dispatch: AppDispatch) => - dispatch(untagUsers([accountId], ['donor'])); - const addPermission = (accountIds: string[], permissionGroup: string) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); @@ -476,6 +493,18 @@ const demoteToUser = (accountId: string) => dispatch(removePermission([accountId], 'moderator')), ]); +const setRole = (accountId: string, role: 'user' | 'moderator' | 'admin') => + (dispatch: AppDispatch) => { + switch (role) { + case 'user': + return dispatch(demoteToUser(accountId)); + case 'moderator': + return dispatch(promoteToModerator(accountId)); + case 'admin': + return dispatch(promoteToAdmin(accountId)); + } + }; + const suggestUsers = (accountIds: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = nicknamesFromIds(getState, accountIds); @@ -567,15 +596,16 @@ export { fetchModerationLog, tagUsers, untagUsers, + setTags, + setBadges, verifyUser, unverifyUser, - setDonor, - removeDonor, addPermission, removePermission, promoteToAdmin, promoteToModerator, demoteToUser, + setRole, suggestUsers, unsuggestUsers, }; diff --git a/app/soapbox/components/badge.tsx b/app/soapbox/components/badge.tsx index 13646bcdb..01b792bd0 100644 --- a/app/soapbox/components/badge.tsx +++ b/app/soapbox/components/badge.tsx @@ -3,24 +3,27 @@ import React from 'react'; interface IBadge { title: React.ReactNode, - slug: 'patron' | 'donor' | 'admin' | 'moderator' | 'bot' | 'opaque', + slug: string, } - /** Badge to display on a user's profile. */ -const Badge: React.FC = ({ title, slug }) => ( - - {title} - -); +const Badge: React.FC = ({ title, slug }) => { + const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug); + + return ( + + {title} + + ); +}; export default Badge; diff --git a/app/soapbox/components/list.tsx b/app/soapbox/components/list.tsx index ae29a5cbd..b16a36784 100644 --- a/app/soapbox/components/list.tsx +++ b/app/soapbox/components/list.tsx @@ -34,7 +34,7 @@ const ListItem: React.FC = ({ label, hint, children, onClick }) => { id: domId, className: classNames({ 'w-auto': isSelect, - }), + }, child.props.className), }); } diff --git a/app/soapbox/components/profile-hover-card.tsx b/app/soapbox/components/profile-hover-card.tsx index c96839c77..3775a107c 100644 --- a/app/soapbox/components/profile-hover-card.tsx +++ b/app/soapbox/components/profile-hover-card.tsx @@ -37,10 +37,6 @@ const getBadges = (account: Account): JSX.Element[] => { badges.push(); } - if (account.donor) { - badges.push(); - } - return badges; }; diff --git a/app/soapbox/components/sidebar_menu.tsx b/app/soapbox/components/sidebar_menu.tsx index 4f349fd1c..817e1f0d2 100644 --- a/app/soapbox/components/sidebar_menu.tsx +++ b/app/soapbox/components/sidebar_menu.tsx @@ -37,6 +37,7 @@ const messages = defineMessages({ invites: { id: 'navigation_bar.invites', defaultMessage: 'Invites' }, developers: { id: 'navigation.developers', defaultMessage: 'Developers' }, addAccount: { id: 'profile_dropdown.add_account', defaultMessage: 'Add an existing account' }, + followRequests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, }); interface ISidebarLink { @@ -87,6 +88,7 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { const otherAccounts: ImmutableList = useAppSelector((state) => getOtherAccounts(state)); const sidebarOpen = useAppSelector((state) => state.sidebar.sidebarOpen); const settings = useAppSelector((state) => getSettings(state)); + const followRequestsCount = useAppSelector((state) => state.user_lists.follow_requests.items.count()); const closeButtonRef = React.useRef(null); @@ -177,6 +179,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => { onClick={onClose} /> + {(account.locked || followRequestsCount > 0) && ( + + )} + {features.bookmarks && ( = ({ } }; - const handleDeactivateUser: React.EventHandler = (e) => { + const onModerate: React.MouseEventHandler = (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 account = status.account as Account; + dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id })); }; const handleDeleteStatus: React.EventHandler = (e) => { @@ -452,13 +448,13 @@ const StatusActionBar: React.FC = ({ if (isStaff) { menu.push(null); + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: username }), + action: onModerate, + icon: require('@tabler/icons/gavel.svg'), + }); + 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}/`, @@ -474,17 +470,6 @@ const StatusActionBar: React.FC = ({ }); 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, diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx index 295538da2..3bee7a03a 100644 --- a/app/soapbox/components/status_list.tsx +++ b/app/soapbox/components/status_list.tsx @@ -1,7 +1,9 @@ import classNames from 'clsx'; +import { Map as ImmutableMap } from 'immutable'; import debounce from 'lodash/debounce'; import React, { useRef, useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; +import { v4 as uuidv4 } from 'uuid'; import LoadGap from 'soapbox/components/load_gap'; import ScrollableList from 'soapbox/components/scrollable_list'; @@ -9,6 +11,7 @@ import StatusContainer from 'soapbox/containers/status_container'; import Ad from 'soapbox/features/ads/components/ad'; import FeedSuggestions from 'soapbox/features/feed-suggestions/feed-suggestions'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; +import { ALGORITHMS } from 'soapbox/features/timeline-insertion'; import PendingStatus from 'soapbox/features/ui/components/pending_status'; import { useSoapboxConfig } from 'soapbox/hooks'; import useAds from 'soapbox/queries/ads'; @@ -60,8 +63,12 @@ const StatusList: React.FC = ({ }) => { const { data: ads } = useAds(); const soapboxConfig = useSoapboxConfig(); - const adsInterval = Number(soapboxConfig.extensions.getIn(['ads', 'interval'], 40)) || 0; + + const adsAlgorithm = String(soapboxConfig.extensions.getIn(['ads', 'algorithm', 0])); + const adsOpts = (soapboxConfig.extensions.getIn(['ads', 'algorithm', 1], ImmutableMap()) as ImmutableMap).toJS(); + const node = useRef(null); + const seed = useRef(uuidv4()); const getFeaturedStatusCount = () => { return featuredStatusIds?.size || 0; @@ -132,9 +139,10 @@ const StatusList: React.FC = ({ ); }; - const renderAd = (ad: AdEntity) => { + const renderAd = (ad: AdEntity, index: number) => { return ( = ({ const renderStatuses = (): React.ReactNode[] => { if (isLoading || statusIds.size > 0) { return statusIds.toList().reduce((acc, statusId, index) => { - const adIndex = ads ? Math.floor((index + 1) / adsInterval) % ads.length : 0; - const ad = ads ? ads[adIndex] : undefined; - const showAd = (index + 1) % adsInterval === 0; + if (showAds && ads) { + const ad = ALGORITHMS[adsAlgorithm]?.(ads, index, { ...adsOpts, seed: seed.current }); + + if (ad) { + acc.push(renderAd(ad, index)); + } + } if (statusId === null) { acc.push(renderLoadGap(index)); @@ -189,10 +201,6 @@ const StatusList: React.FC = ({ acc.push(renderStatus(statusId)); } - if (showAds && ad && showAd) { - acc.push(renderAd(ad)); - } - return acc; }, [] as React.ReactNode[]); } else { diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index 5eff0a78f..fd9cb055e 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -34,6 +34,7 @@ export { default as Select } from './select/select'; export { default as Spinner } from './spinner/spinner'; export { default as Stack } from './stack/stack'; export { default as Tabs } from './tabs/tabs'; +export { default as TagInput } from './tag-input/tag-input'; export { default as Text } from './text/text'; export { default as Textarea } from './textarea/textarea'; export { default as Toggle } from './toggle/toggle'; diff --git a/app/soapbox/components/ui/tag-input/tag-input.tsx b/app/soapbox/components/ui/tag-input/tag-input.tsx new file mode 100644 index 000000000..e4f8548ab --- /dev/null +++ b/app/soapbox/components/ui/tag-input/tag-input.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; + +import HStack from '../hstack/hstack'; + +import Tag from './tag'; + +interface ITagInput { + tags: string[], + onChange: (tags: string[]) => void, + placeholder?: string, +} + +/** Manage a list of tags. */ +// https://blog.logrocket.com/building-a-tag-input-field-component-for-react/ +const TagInput: React.FC = ({ tags, onChange, placeholder }) => { + const [input, setInput] = useState(''); + + const handleTagDelete = (tag: string) => { + onChange(tags.filter(item => item !== tag)); + }; + + const handleKeyDown: React.KeyboardEventHandler = (e) => { + const { key } = e; + const trimmedInput = input.trim(); + + if (key === 'Tab') { + e.preventDefault(); + } + + if ([',', 'Tab', 'Enter'].includes(key) && trimmedInput.length && !tags.includes(trimmedInput)) { + e.preventDefault(); + onChange([...tags, trimmedInput]); + setInput(''); + } + + if (key === 'Backspace' && !input.length && tags.length) { + e.preventDefault(); + const tagsCopy = [...tags]; + tagsCopy.pop(); + + onChange(tagsCopy); + } + }; + + return ( +
+ + {tags.map((tag, i) => ( +
+ +
+ ))} + + setInput(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+
+ ); +}; + +export default TagInput; \ No newline at end of file diff --git a/app/soapbox/components/ui/tag-input/tag.tsx b/app/soapbox/components/ui/tag-input/tag.tsx new file mode 100644 index 000000000..d47b1d73b --- /dev/null +++ b/app/soapbox/components/ui/tag-input/tag.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import IconButton from '../icon-button/icon-button'; +import Text from '../text/text'; + +interface ITag { + /** Name of the tag. */ + tag: string, + /** Callback when the X icon is pressed. */ + onDelete: (tag: string) => void, +} + +/** A single editable Tag (used by TagInput). */ +const Tag: React.FC = ({ tag, onDelete }) => { + return ( +
+ {tag} + + onDelete(tag)} + transparent + /> +
+ ); +}; + +export default Tag; \ No newline at end of file diff --git a/app/soapbox/containers/status_container.tsx b/app/soapbox/containers/status_container.tsx index 3f7c1a34f..89c387ffd 100644 --- a/app/soapbox/containers/status_container.tsx +++ b/app/soapbox/containers/status_container.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import Status, { IStatus } from 'soapbox/components/status'; import { useAppSelector } from 'soapbox/hooks'; @@ -16,14 +16,14 @@ interface IStatusContainer extends Omit { 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 = (props) => { const { id, ...rest } = props; + + const getStatus = useCallback(makeGetStatus(), []); const status = useAppSelector(state => getStatus(state, { id })); if (status) { diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index c9620485b..925a63b16 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -6,12 +6,10 @@ 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'; @@ -26,10 +24,7 @@ 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 { isRemote } from 'soapbox/utils/accounts'; import type { Menu as MenuType } from 'soapbox/components/dropdown_menu'; @@ -59,40 +54,17 @@ const messages = defineMessages({ 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}' }, + adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{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}' }, searchSelf: { id: 'account.search_self', defaultMessage: 'Search your posts' }, 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 { @@ -210,81 +182,8 @@ const Header: React.FC = ({ account }) => { 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 onModerate = () => { + dispatch(openModal('ACCOUNT_MODERATION', { accountId: account.id })); }; const onRemoveFromFollowers = () => { @@ -532,107 +431,11 @@ const Header: React.FC = ({ account }) => { 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'), - }); - } + menu.push({ + text: intl.formatMessage(messages.adminAccount, { name: account.username }), + action: onModerate, + icon: require('@tabler/icons/gavel.svg'), + }); } return menu; diff --git a/app/soapbox/features/admin/components/report.tsx b/app/soapbox/features/admin/components/report.tsx index fb1c5bf5d..75c3ecb1d 100644 --- a/app/soapbox/features/admin/components/report.tsx +++ b/app/soapbox/features/admin/components/report.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { Link } from 'react-router-dom'; @@ -10,7 +10,8 @@ import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper'; import { Button, HStack } from 'soapbox/components/ui'; import DropdownMenu from 'soapbox/containers/dropdown_menu_container'; import Accordion from 'soapbox/features/ui/components/accordion'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; +import { makeGetReport } from 'soapbox/selectors'; import ReportStatus from './report_status'; @@ -24,15 +25,21 @@ const messages = defineMessages({ }); interface IReport { - report: AdminReport; + id: string; } -const Report: React.FC = ({ report }) => { +const Report: React.FC = ({ id }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const getReport = useCallback(makeGetReport(), []); + + const report = useAppSelector((state) => getReport(state, id) as AdminReport | undefined); + const [accordionExpanded, setAccordionExpanded] = useState(false); + if (!report) return null; + const account = report.account as Account; const targetAccount = report.target_account as Account; diff --git a/app/soapbox/features/admin/components/unapproved_account.tsx b/app/soapbox/features/admin/components/unapproved_account.tsx index b5a08c956..a01421643 100644 --- a/app/soapbox/features/admin/components/unapproved_account.tsx +++ b/app/soapbox/features/admin/components/unapproved_account.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { approveUsers } from 'soapbox/actions/admin'; @@ -13,8 +13,6 @@ const messages = defineMessages({ rejected: { id: 'admin.awaiting_approval.rejected_message', defaultMessage: '{acct} was rejected.' }, }); -const getAccount = makeGetAccount(); - interface IUnapprovedAccount { accountId: string, } @@ -23,6 +21,7 @@ interface IUnapprovedAccount { const UnapprovedAccount: React.FC = ({ accountId }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector(state => getAccount(state, accountId)); const adminAccount = useAppSelector(state => state.admin.users.get(accountId)); diff --git a/app/soapbox/features/admin/tabs/reports.tsx b/app/soapbox/features/admin/tabs/reports.tsx index 8a2eca8c7..5c2854cc4 100644 --- a/app/soapbox/features/admin/tabs/reports.tsx +++ b/app/soapbox/features/admin/tabs/reports.tsx @@ -4,7 +4,6 @@ import { defineMessages, useIntl } from 'react-intl'; import { fetchReports } from 'soapbox/actions/admin'; import ScrollableList from 'soapbox/components/scrollable_list'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; -import { makeGetReport } from 'soapbox/selectors'; import Report from '../components/report'; @@ -14,18 +13,13 @@ const messages = defineMessages({ emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' }, }); -const getReport = makeGetReport(); - const Reports: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); const [isLoading, setLoading] = useState(true); - const reports = useAppSelector(state => { - const ids = state.admin.openReports; - return ids.toList().map(id => getReport(state, id)); - }); + const reports = useAppSelector(state => state.admin.openReports.toList()); useEffect(() => { dispatch(fetchReports()) @@ -42,7 +36,7 @@ const Reports: React.FC = () => { scrollKey='admin-reports' emptyMessage={intl.formatMessage(messages.emptyMessage)} > - {reports.map(report => report && )} + {reports.map(report => report && )} ); }; diff --git a/app/soapbox/features/aliases/components/account.tsx b/app/soapbox/features/aliases/components/account.tsx index 3d24c0543..be9178b8d 100644 --- a/app/soapbox/features/aliases/components/account.tsx +++ b/app/soapbox/features/aliases/components/account.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { addToAliases } from 'soapbox/actions/aliases'; @@ -15,8 +15,6 @@ const messages = defineMessages({ add: { id: 'aliases.account.add', defaultMessage: 'Create alias' }, }); -const getAccount = makeGetAccount(); - interface IAccount { accountId: string, aliases: ImmutableList @@ -25,6 +23,8 @@ interface IAccount { const Account: React.FC = ({ accountId, aliases }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, accountId)); const added = useAppSelector((state) => { diff --git a/app/soapbox/features/birthdays/account.tsx b/app/soapbox/features/birthdays/account.tsx index 738ad5ec9..745ca5268 100644 --- a/app/soapbox/features/birthdays/account.tsx +++ b/app/soapbox/features/birthdays/account.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import Avatar from 'soapbox/components/avatar'; @@ -12,14 +12,14 @@ const messages = defineMessages({ birthday: { id: 'account.birthday', defaultMessage: 'Born {date}' }, }); -const getAccount = makeGetAccount(); - interface IAccount { accountId: string, } const Account: React.FC = ({ accountId }) => { const intl = useIntl(); + const getAccount = useCallback(makeGetAccount(), []); + const account = useAppSelector((state) => getAccount(state, accountId)); // useEffect(() => { @@ -30,7 +30,7 @@ const Account: React.FC = ({ accountId }) => { if (!account) return null; - const birthday = account.get('birthday'); + const birthday = account.birthday; if (!birthday) return null; const formattedBirthday = intl.formatDate(birthday, { day: 'numeric', month: 'short', year: 'numeric' }); @@ -38,7 +38,7 @@ const Account: React.FC = ({ accountId }) => { return (
- +
diff --git a/app/soapbox/features/chats/components/chat.tsx b/app/soapbox/features/chats/components/chat.tsx index 13d2b7fee..2083ce19d 100644 --- a/app/soapbox/features/chats/components/chat.tsx +++ b/app/soapbox/features/chats/components/chat.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; import Avatar from 'soapbox/components/avatar'; @@ -11,14 +11,13 @@ import { makeGetChat } from 'soapbox/selectors'; import type { Account as AccountEntity, Chat as ChatEntity } from 'soapbox/types/entities'; -const getChat = makeGetChat(); - interface IChat { chatId: string, onClick: (chat: any) => void, } const Chat: React.FC = ({ chatId, onClick }) => { + const getChat = useCallback(makeGetChat(), []); const chat = useAppSelector((state) => { const chat = state.chats.items.get(chatId); return chat ? getChat(state, (chat as any).toJS()) : undefined; diff --git a/app/soapbox/features/compose/components/autosuggest_account.tsx b/app/soapbox/features/compose/components/autosuggest_account.tsx index 6b65f8341..511d65fe6 100644 --- a/app/soapbox/features/compose/components/autosuggest_account.tsx +++ b/app/soapbox/features/compose/components/autosuggest_account.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import Account from 'soapbox/components/account'; import { useAppSelector } from 'soapbox/hooks'; @@ -9,7 +9,7 @@ interface IAutosuggestAccount { } const AutosuggestAccount: React.FC = ({ id }) => { - const getAccount = makeGetAccount(); + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, id)); if (!account) return null; diff --git a/app/soapbox/features/compose/components/reply_mentions.tsx b/app/soapbox/features/compose/components/reply_mentions.tsx index 0fe8e0b73..577334281 100644 --- a/app/soapbox/features/compose/components/reply_mentions.tsx +++ b/app/soapbox/features/compose/components/reply_mentions.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { FormattedList, FormattedMessage } from 'react-intl'; import { useDispatch } from 'react-redux'; @@ -16,11 +16,12 @@ interface IReplyMentions { const ReplyMentions: React.FC = ({ composeId }) => { const dispatch = useDispatch(); + const getStatus = useCallback(makeGetStatus(), []); const compose = useCompose(composeId); const instance = useAppSelector((state) => state.instance); - const status = useAppSelector(state => makeGetStatus()(state, { id: compose.in_reply_to! })); + const status = useAppSelector(state => getStatus(state, { id: compose.in_reply_to! })); const to = compose.to; const account = useAppSelector((state) => state.accounts.get(state.me)); diff --git a/app/soapbox/features/compose/containers/quoted_status_container.tsx b/app/soapbox/features/compose/containers/quoted_status_container.tsx index 4fd9cdaf2..39416867b 100644 --- a/app/soapbox/features/compose/containers/quoted_status_container.tsx +++ b/app/soapbox/features/compose/containers/quoted_status_container.tsx @@ -1,12 +1,10 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { cancelQuoteCompose } from 'soapbox/actions/compose'; import QuotedStatus from 'soapbox/components/quoted-status'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; -const getStatus = makeGetStatus(); - interface IQuotedStatusContainer { composeId: string, } @@ -14,6 +12,8 @@ interface IQuotedStatusContainer { /** QuotedStatus shown in post composer. */ const QuotedStatusContainer: React.FC = ({ composeId }) => { const dispatch = useAppDispatch(); + const getStatus = useCallback(makeGetStatus(), []); + const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! })); const onCancel = () => { diff --git a/app/soapbox/features/embedded-status/index.tsx b/app/soapbox/features/embedded-status/index.tsx index 8187084e0..931b3fb66 100644 --- a/app/soapbox/features/embedded-status/index.tsx +++ b/app/soapbox/features/embedded-status/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { fetchStatus } from 'soapbox/actions/statuses'; @@ -16,12 +16,12 @@ interface IEmbeddedStatus { }, } -const getStatus = makeGetStatus(); - /** Status to be presented in an iframe for embeds on external websites. */ const EmbeddedStatus: React.FC = ({ params }) => { const dispatch = useAppDispatch(); const history = useHistory(); + const getStatus = useCallback(makeGetStatus(), []); + const status = useAppSelector(state => getStatus(state, { id: params.statusId })); const [loading, setLoading] = useState(true); diff --git a/app/soapbox/features/favourited_statuses/index.js b/app/soapbox/features/favourited_statuses/index.js deleted file mode 100644 index b4ffb6a8a..000000000 --- a/app/soapbox/features/favourited_statuses/index.js +++ /dev/null @@ -1,158 +0,0 @@ -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, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts'; -import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import StatusList from 'soapbox/components/status_list'; -import { Spinner } from 'soapbox/components/ui'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' }, -}); - -const mapStateToProps = (state, { params }) => { - const username = params.username || ''; - const me = state.get('me'); - const meUsername = state.getIn(['accounts', me, 'username'], ''); - - const isMyAccount = (username.toLowerCase() === meUsername.toLowerCase()); - - const features = getFeatures(state.get('instance')); - - if (isMyAccount) { - return { - isMyAccount, - statusIds: state.status_lists.get('favourites').items, - isLoading: state.status_lists.get('favourites').isLoading, - hasMore: !!state.status_lists.get('favourites').next, - }; - } - - const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase()); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - isMyAccount, - accountId, - unavailable, - username, - isAccount: !!state.getIn(['accounts', accountId]), - statusIds: state.status_lists.get(`favourites:${accountId}`)?.items || [], - isLoading: state.status_lists.get(`favourites:${accountId}`)?.isLoading, - hasMore: !!state.status_lists.get(`favourites:${accountId}`)?.next, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Favourites extends ImmutablePureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - intl: PropTypes.object.isRequired, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - isMyAccount: PropTypes.bool.isRequired, - }; - - componentDidMount() { - const { accountId, isMyAccount, username } = this.props; - - if (isMyAccount) - this.props.dispatch(fetchFavouritedStatuses()); - else { - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchAccountFavouritedStatuses(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - } - - componentDidUpdate(prevProps) { - const { accountId, isMyAccount } = this.props; - - if (!isMyAccount && accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchAccountFavouritedStatuses(accountId)); - } - } - - handleLoadMore = debounce(() => { - const { accountId, isMyAccount } = this.props; - - if (isMyAccount) { - this.props.dispatch(expandFavouritedStatuses()); - } else { - this.props.dispatch(expandAccountFavouritedStatuses(accountId)); - } - }, 300, { leading: true }) - - render() { - const { intl, statusIds, isLoading, hasMore, isMyAccount, isAccount, accountId, unavailable } = this.props; - - if (!isMyAccount && !isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1) { - return ( - - - - ); - } - - if (unavailable) { - return ( - -
- -
-
- ); - } - - const emptyMessage = isMyAccount - ? - : ; - - return ( - - - - ); - } - -} diff --git a/app/soapbox/features/favourited_statuses/index.tsx b/app/soapbox/features/favourited_statuses/index.tsx new file mode 100644 index 000000000..1a8ba3bfa --- /dev/null +++ b/app/soapbox/features/favourited_statuses/index.tsx @@ -0,0 +1,108 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts'; +import { fetchFavouritedStatuses, expandFavouritedStatuses, fetchAccountFavouritedStatuses, expandAccountFavouritedStatuses } from 'soapbox/actions/favourites'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import StatusList from 'soapbox/components/status_list'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.favourited_statuses', defaultMessage: 'Liked posts' }, +}); + +interface IFavourites { + params?: { + username?: string, + } +} + +/** Timeline displaying a user's favourited statuses. */ +const Favourites: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const timelineKey = isOwnAccount ? 'favourites' : `favourites:${account?.id}`; + const statusIds = useAppSelector(state => state.status_lists.get(timelineKey)?.items || ImmutableOrderedSet()); + const isLoading = useAppSelector(state => state.status_lists.get(timelineKey)?.isLoading === true); + const hasMore = useAppSelector(state => !!state.status_lists.get(timelineKey)?.next); + + const isUnavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + const handleLoadMore = useCallback(debounce(() => { + if (isOwnAccount) { + dispatch(expandFavouritedStatuses()); + } else if (account) { + dispatch(expandAccountFavouritedStatuses(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + useEffect(() => { + if (isOwnAccount) + dispatch(fetchFavouritedStatuses()); + else { + if (account) { + dispatch(fetchAccount(account.id)); + dispatch(fetchAccountFavouritedStatuses(account.id)); + } else { + dispatch(fetchAccountByUsername(username)); + } + } + }, []); + + useEffect(() => { + if (account && !isOwnAccount) { + dispatch(fetchAccount(account.id)); + dispatch(fetchAccountFavouritedStatuses(account.id)); + } + }, [account?.id]); + + if (isUnavailable) { + return ( + +
+ +
+
+ ); + } + + if (!account) { + return ( + + ); + } + + const emptyMessage = isOwnAccount + ? + : ; + + return ( + + + + ); +}; + +export default Favourites; \ No newline at end of file diff --git a/app/soapbox/features/follow_requests/components/account_authorize.tsx b/app/soapbox/features/follow_requests/components/account_authorize.tsx index 6573bbd55..b820938d6 100644 --- a/app/soapbox/features/follow_requests/components/account_authorize.tsx +++ b/app/soapbox/features/follow_requests/components/account_authorize.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { useDispatch } from 'react-redux'; @@ -16,8 +16,6 @@ const messages = defineMessages({ reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }, }); -const getAccount = makeGetAccount(); - interface IAccountAuthorize { id: string, } @@ -25,6 +23,8 @@ interface IAccountAuthorize { const AccountAuthorize: React.FC = ({ id }) => { const intl = useIntl(); const dispatch = useDispatch(); + + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, id)); diff --git a/app/soapbox/features/followers/index.js b/app/soapbox/features/followers/index.js deleted file mode 100644 index a25fb0d7d..000000000 --- a/app/soapbox/features/followers/index.js +++ /dev/null @@ -1,138 +0,0 @@ -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, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchFollowers, - expandFollowers, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account_container'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFollowDifference } from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.followers', defaultMessage: 'Followers' }, -}); - -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 features = getFeatures(state.get('instance')); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const diffCount = getFollowDifference(state, accountId, 'followers'); - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.user_lists.followers.get(accountId)?.items, - hasMore: !!state.user_lists.followers.get(accountId)?.next, - diffCount, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Followers extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - diffCount: PropTypes.number, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchFollowers(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, dispatch } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - dispatch(fetchAccount(accountId)); - dispatch(fetchFollowers(accountId)); - } - } - - handleLoadMore = debounce(() => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandFollowers(this.props.accountId)); - } - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore, diffCount, isAccount, accountId, unavailable } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!accountIds)) { - return ( - - ); - } - - if (unavailable) { - return ( -
- -
- ); - } - - return ( - - } - itemClassName='pb-4' - > - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/followers/index.tsx b/app/soapbox/features/followers/index.tsx new file mode 100644 index 000000000..8625f2142 --- /dev/null +++ b/app/soapbox/features/followers/index.tsx @@ -0,0 +1,115 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + fetchAccount, + fetchFollowers, + expandFollowers, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.followers', defaultMessage: 'Followers' }, +}); + +interface IFollowers { + params?: { + username?: string, + } +} + +/** Displays a list of accounts who follow the given account. */ +const Followers: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const [loading, setLoading] = useState(true); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const accountIds = useAppSelector(state => state.user_lists.followers.get(account!?.id)?.items || ImmutableOrderedSet()); + const hasMore = useAppSelector(state => !!state.user_lists.followers.get(account!?.id)?.next); + + const isUnavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + const handleLoadMore = useCallback(debounce(() => { + if (account) { + dispatch(expandFollowers(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + useEffect(() => { + let promises = []; + + if (account) { + promises = [ + dispatch(fetchAccount(account.id)), + dispatch(fetchFollowers(account.id)), + ]; + } else { + promises = [ + dispatch(fetchAccountByUsername(username)), + ]; + } + + Promise.all(promises) + .then(() => setLoading(false)) + .catch(() => setLoading(false)); + + }, [account?.id, username]); + + if (loading && accountIds.isEmpty()) { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + if (isUnavailable) { + return ( +
+ +
+ ); + } + + return ( + + } + itemClassName='pb-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Followers; \ No newline at end of file diff --git a/app/soapbox/features/following/index.js b/app/soapbox/features/following/index.js deleted file mode 100644 index 682a26411..000000000 --- a/app/soapbox/features/following/index.js +++ /dev/null @@ -1,138 +0,0 @@ -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, injectIntl, FormattedMessage } from 'react-intl'; -import { connect } from 'react-redux'; - -import { - fetchAccount, - fetchFollowing, - expandFollowing, - fetchAccountByUsername, -} from 'soapbox/actions/accounts'; -import MissingIndicator from 'soapbox/components/missing_indicator'; -import ScrollableList from 'soapbox/components/scrollable_list'; -import { Spinner } from 'soapbox/components/ui'; -import AccountContainer from 'soapbox/containers/account_container'; -import { findAccountByUsername } from 'soapbox/selectors'; -import { getFollowDifference } from 'soapbox/utils/accounts'; -import { getFeatures } from 'soapbox/utils/features'; - -import Column from '../ui/components/column'; - -const messages = defineMessages({ - heading: { id: 'column.following', defaultMessage: 'Following' }, -}); - -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 features = getFeatures(state.get('instance')); - - let accountId = -1; - if (accountFetchError) { - accountId = null; - } else { - const account = findAccountByUsername(state, username); - accountId = account ? account.getIn(['id'], null) : -1; - } - - const diffCount = getFollowDifference(state, accountId, 'following'); - const isBlocked = state.getIn(['relationships', accountId, 'blocked_by'], false); - const unavailable = (me === accountId) ? false : (isBlocked && !features.blockersVisible); - - return { - accountId, - unavailable, - isAccount: !!state.getIn(['accounts', accountId]), - accountIds: state.user_lists.following.get(accountId)?.items, - hasMore: !!state.user_lists.following.get(accountId)?.next, - diffCount, - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class Following extends ImmutablePureComponent { - - static propTypes = { - intl: PropTypes.object.isRequired, - params: PropTypes.object.isRequired, - dispatch: PropTypes.func.isRequired, - accountIds: ImmutablePropTypes.orderedSet, - hasMore: PropTypes.bool, - isAccount: PropTypes.bool, - unavailable: PropTypes.bool, - diffCount: PropTypes.number, - }; - - componentDidMount() { - const { params: { username }, accountId } = this.props; - - if (accountId && accountId !== -1) { - this.props.dispatch(fetchAccount(accountId)); - this.props.dispatch(fetchFollowing(accountId)); - } else { - this.props.dispatch(fetchAccountByUsername(username)); - } - } - - componentDidUpdate(prevProps) { - const { accountId, dispatch } = this.props; - if (accountId && accountId !== -1 && (accountId !== prevProps.accountId && accountId)) { - dispatch(fetchAccount(accountId)); - dispatch(fetchFollowing(accountId)); - } - } - - handleLoadMore = debounce(() => { - if (this.props.accountId && this.props.accountId !== -1) { - this.props.dispatch(expandFollowing(this.props.accountId)); - } - }, 300, { leading: true }); - - render() { - const { intl, accountIds, hasMore, isAccount, diffCount, accountId, unavailable } = this.props; - - if (!isAccount && accountId !== -1) { - return ( - - ); - } - - if (accountId === -1 || (!accountIds)) { - return ( - - ); - } - - if (unavailable) { - return ( -
- -
- ); - } - - return ( - - } - itemClassName='pb-4' - > - {accountIds.map(id => - , - )} - - - ); - } - -} diff --git a/app/soapbox/features/following/index.tsx b/app/soapbox/features/following/index.tsx new file mode 100644 index 000000000..cd0a10351 --- /dev/null +++ b/app/soapbox/features/following/index.tsx @@ -0,0 +1,115 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + fetchAccount, + fetchFollowing, + expandFollowing, + fetchAccountByUsername, +} from 'soapbox/actions/accounts'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import { Spinner } from 'soapbox/components/ui'; +import AccountContainer from 'soapbox/containers/account_container'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { findAccountByUsername } from 'soapbox/selectors'; + +import Column from '../ui/components/column'; + +const messages = defineMessages({ + heading: { id: 'column.following', defaultMessage: 'Following' }, +}); + +interface IFollowing { + params?: { + username?: string, + } +} + +/** Displays a list of accounts the given user is following. */ +const Following: React.FC = (props) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const features = useFeatures(); + const ownAccount = useOwnAccount(); + + const [loading, setLoading] = useState(true); + + const username = props.params?.username || ''; + const account = useAppSelector(state => findAccountByUsername(state, username)); + const isOwnAccount = username.toLowerCase() === ownAccount?.username?.toLowerCase(); + + const accountIds = useAppSelector(state => state.user_lists.following.get(account!?.id)?.items || ImmutableOrderedSet()); + const hasMore = useAppSelector(state => !!state.user_lists.following.get(account!?.id)?.next); + + const isUnavailable = useAppSelector(state => { + const blockedBy = state.relationships.getIn([account?.id, 'blocked_by']) === true; + return isOwnAccount ? false : (blockedBy && !features.blockersVisible); + }); + + const handleLoadMore = useCallback(debounce(() => { + if (account) { + dispatch(expandFollowing(account.id)); + } + }, 300, { leading: true }), [account?.id]); + + useEffect(() => { + let promises = []; + + if (account) { + promises = [ + dispatch(fetchAccount(account.id)), + dispatch(fetchFollowing(account.id)), + ]; + } else { + promises = [ + dispatch(fetchAccountByUsername(username)), + ]; + } + + Promise.all(promises) + .then(() => setLoading(false)) + .catch(() => setLoading(false)); + + }, [account?.id, username]); + + if (loading && accountIds.isEmpty()) { + return ( + + ); + } + + if (!account) { + return ( + + ); + } + + if (isUnavailable) { + return ( +
+ +
+ ); + } + + return ( + + } + itemClassName='pb-4' + > + {accountIds.map(id => + , + )} + + + ); +}; + +export default Following; \ No newline at end of file diff --git a/app/soapbox/features/landing_page/index.tsx b/app/soapbox/features/landing_page/index.tsx index 1e3b01f80..76e77bcfa 100644 --- a/app/soapbox/features/landing_page/index.tsx +++ b/app/soapbox/features/landing_page/index.tsx @@ -113,7 +113,10 @@ const LandingPage = () => { - {instance.description} +
diff --git a/app/soapbox/features/list_adder/components/account.tsx b/app/soapbox/features/list_adder/components/account.tsx index 304b32207..e35a0ce96 100644 --- a/app/soapbox/features/list_adder/components/account.tsx +++ b/app/soapbox/features/list_adder/components/account.tsx @@ -1,17 +1,17 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import DisplayName from 'soapbox/components/display-name'; import { Avatar } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; import { makeGetAccount } from 'soapbox/selectors'; -const getAccount = makeGetAccount(); - interface IAccount { accountId: string, } const Account: React.FC = ({ accountId }) => { + const getAccount = useCallback(makeGetAccount(), []); + const account = useAppSelector((state) => getAccount(state, accountId)); if (!account) return null; diff --git a/app/soapbox/features/list_editor/components/account.tsx b/app/soapbox/features/list_editor/components/account.tsx index 79916ead2..ef1b2e246 100644 --- a/app/soapbox/features/list_editor/components/account.tsx +++ b/app/soapbox/features/list_editor/components/account.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { removeFromListEditor, addToListEditor } from 'soapbox/actions/lists'; @@ -13,8 +13,6 @@ const messages = defineMessages({ add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, }); -const getAccount = makeGetAccount(); - interface IAccount { accountId: string, } @@ -22,6 +20,7 @@ interface IAccount { const Account: React.FC = ({ accountId }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const getAccount = useCallback(makeGetAccount(), []); const account = useAppSelector((state) => getAccount(state, accountId)); const isAdded = useAppSelector((state) => state.listEditor.accounts.items.includes(accountId)); diff --git a/app/soapbox/features/notifications/components/notification.tsx b/app/soapbox/features/notifications/components/notification.tsx index b7e528ef5..e1fa87c5a 100644 --- a/app/soapbox/features/notifications/components/notification.tsx +++ b/app/soapbox/features/notifications/components/notification.tsx @@ -20,8 +20,6 @@ import { NotificationType, validType } from 'soapbox/utils/notification'; import type { ScrollPosition } from 'soapbox/components/status'; import type { Account, Status as StatusEntity, Notification as NotificationEntity } from 'soapbox/types/entities'; -const getNotification = makeGetNotification(); - const notificationForScreenReader = (intl: IntlShape, message: string, timestamp: Date) => { const output = [message]; @@ -153,6 +151,8 @@ const Notification: React.FC = (props) => { const dispatch = useAppDispatch(); + const getNotification = useCallback(makeGetNotification(), []); + const notification = useAppSelector((state) => getNotification(state, props.notification)); const history = useHistory(); diff --git a/app/soapbox/features/reply_mentions/account.tsx b/app/soapbox/features/reply_mentions/account.tsx index ffb960840..87ba71278 100644 --- a/app/soapbox/features/reply_mentions/account.tsx +++ b/app/soapbox/features/reply_mentions/account.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { fetchAccount } from 'soapbox/actions/accounts'; @@ -14,8 +14,6 @@ const messages = defineMessages({ add: { id: 'reply_mentions.account.add', defaultMessage: 'Add to mentions' }, }); -const getAccount = makeGetAccount(); - interface IAccount { composeId: string, accountId: string, @@ -25,6 +23,7 @@ interface IAccount { const Account: React.FC = ({ composeId, accountId, author }) => { const intl = useIntl(); const dispatch = useAppDispatch(); + const getAccount = useCallback(makeGetAccount(), []); const compose = useCompose(composeId); diff --git a/app/soapbox/features/soapbox_config/index.tsx b/app/soapbox/features/soapbox_config/index.tsx index 24ad615a3..fd339cf5c 100644 --- a/app/soapbox/features/soapbox_config/index.tsx +++ b/app/soapbox/features/soapbox_config/index.tsx @@ -155,7 +155,7 @@ const SoapboxConfig: React.FC = () => { const addStreamItem = (path: ConfigPath, template: Template) => { return () => { - const items = data.getIn(path); + const items = data.getIn(path) || ImmutableList(); setConfig(path, items.push(template)); }; }; diff --git a/app/soapbox/features/status/containers/quoted_status_container.tsx b/app/soapbox/features/status/containers/quoted_status_container.tsx index f64ad2255..a870aa2c8 100644 --- a/app/soapbox/features/status/containers/quoted_status_container.tsx +++ b/app/soapbox/features/status/containers/quoted_status_container.tsx @@ -1,17 +1,17 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import QuotedStatus from 'soapbox/components/quoted-status'; import { useAppSelector } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; -const getStatus = makeGetStatus(); - interface IQuotedStatusContainer { /** Status ID to the quoted status. */ statusId: string, } const QuotedStatusContainer: React.FC = ({ statusId }) => { + const getStatus = useCallback(makeGetStatus(), []); + const status = useAppSelector(state => getStatus(state, { id: statusId })); if (!status) { diff --git a/app/soapbox/features/status/index.tsx b/app/soapbox/features/status/index.tsx index be5d6bf4c..81cb0a152 100644 --- a/app/soapbox/features/status/index.tsx +++ b/app/soapbox/features/status/index.tsx @@ -68,8 +68,6 @@ const messages = defineMessages({ blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, }); -const getStatus = makeGetStatus(); - const getAncestorsIds = createSelector([ (_: RootState, statusId: string | undefined) => statusId, (state: RootState) => state.contexts.inReplyTos, @@ -131,6 +129,7 @@ const Thread: React.FC = (props) => { const dispatch = useAppDispatch(); const settings = useSettings(); + const getStatus = useCallback(makeGetStatus(), []); const me = useAppSelector(state => state.me); const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); diff --git a/app/soapbox/features/timeline-insertion/__tests__/abovefold.test.ts b/app/soapbox/features/timeline-insertion/__tests__/abovefold.test.ts new file mode 100644 index 000000000..81de8c1a4 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/__tests__/abovefold.test.ts @@ -0,0 +1,18 @@ +import { abovefoldAlgorithm } from '../abovefold'; + +const DATA = Object.freeze(['a', 'b', 'c', 'd']); + +test('abovefoldAlgorithm', () => { + const result = Array(50).fill('').map((_, i) => { + return abovefoldAlgorithm(DATA, i, { seed: '!', range: [2, 6], pageSize: 20 }); + }); + + // console.log(result); + expect(result[0]).toBe(undefined); + expect(result[4]).toBe('a'); + expect(result[5]).toBe(undefined); + expect(result[24]).toBe('b'); + expect(result[30]).toBe(undefined); + expect(result[42]).toBe('c'); + expect(result[43]).toBe(undefined); +}); \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/__tests__/linear.test.ts b/app/soapbox/features/timeline-insertion/__tests__/linear.test.ts new file mode 100644 index 000000000..09d484f12 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/__tests__/linear.test.ts @@ -0,0 +1,19 @@ +import { linearAlgorithm } from '../linear'; + +const DATA = Object.freeze(['a', 'b', 'c', 'd']); + +test('linearAlgorithm', () => { + const result = Array(50).fill('').map((_, i) => { + return linearAlgorithm(DATA, i, { interval: 5 }); + }); + + // console.log(result); + expect(result[0]).toBe(undefined); + expect(result[4]).toBe('a'); + expect(result[8]).toBe(undefined); + expect(result[9]).toBe('b'); + expect(result[10]).toBe(undefined); + expect(result[14]).toBe('c'); + expect(result[15]).toBe(undefined); + expect(result[19]).toBe('d'); +}); \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/abovefold.ts b/app/soapbox/features/timeline-insertion/abovefold.ts new file mode 100644 index 000000000..9ca2f5e28 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/abovefold.ts @@ -0,0 +1,52 @@ +import seedrandom from 'seedrandom'; + +import type { PickAlgorithm } from './types'; + +type Opts = { + /** Randomization seed. */ + seed: string, + /** + * Start/end index of the slot by which one item will be randomly picked per page. + * + * Eg. `[2, 6]` will cause one item to be picked among the third through seventh indexes. + * + * `end` must be larger than `start`. + */ + range: [start: number, end: number], + /** Number of items in the page. */ + pageSize: number, +}; + +/** + * Algorithm to display items per-page. + * One item is randomly inserted into each page within the index range. + */ +const abovefoldAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); + /** Current page of the index. */ + const page = Math.floor(iteration / opts.pageSize); + /** Current index within the page. */ + const pageIndex = (iteration % opts.pageSize); + /** RNG for the page. */ + const rng = seedrandom(`${opts.seed}-page-${page}`); + /** Index to insert the item. */ + const insertIndex = Math.floor(rng() * (opts.range[1] - opts.range[0])) + opts.range[0]; + + if (pageIndex === insertIndex) { + return items[page % items.length]; + } +}; + +const normalizeOpts = (opts: unknown): Opts => { + const { seed, range, pageSize } = (opts && typeof opts === 'object' ? opts : {}) as Record; + + return { + seed: typeof seed === 'string' ? seed : '', + range: Array.isArray(range) ? [Number(range[0]), Number(range[1])] : [2, 6], + pageSize: typeof pageSize === 'number' ? pageSize : 20, + }; +}; + +export { + abovefoldAlgorithm, +}; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/index.ts b/app/soapbox/features/timeline-insertion/index.ts new file mode 100644 index 000000000..f4e00ed29 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/index.ts @@ -0,0 +1,11 @@ +import { abovefoldAlgorithm } from './abovefold'; +import { linearAlgorithm } from './linear'; + +import type { PickAlgorithm } from './types'; + +const ALGORITHMS: Record = { + 'linear': linearAlgorithm, + 'abovefold': abovefoldAlgorithm, +}; + +export { ALGORITHMS }; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/linear.ts b/app/soapbox/features/timeline-insertion/linear.ts new file mode 100644 index 000000000..a542e1fce --- /dev/null +++ b/app/soapbox/features/timeline-insertion/linear.ts @@ -0,0 +1,28 @@ +import type { PickAlgorithm } from './types'; + +type Opts = { + /** Number of iterations until the next item is picked. */ + interval: number, +}; + +/** Picks the next item every iteration. */ +const linearAlgorithm: PickAlgorithm = (items, iteration, rawOpts) => { + const opts = normalizeOpts(rawOpts); + const itemIndex = items ? Math.floor(iteration / opts.interval) % items.length : 0; + const item = items ? items[itemIndex] : undefined; + const showItem = (iteration + 1) % opts.interval === 0; + + return showItem ? item : undefined; +}; + +const normalizeOpts = (opts: unknown): Opts => { + const { interval } = (opts && typeof opts === 'object' ? opts : {}) as Record; + + return { + interval: typeof interval === 'number' ? interval : 20, + }; +}; + +export { + linearAlgorithm, +}; \ No newline at end of file diff --git a/app/soapbox/features/timeline-insertion/types.ts b/app/soapbox/features/timeline-insertion/types.ts new file mode 100644 index 000000000..b874754d0 --- /dev/null +++ b/app/soapbox/features/timeline-insertion/types.ts @@ -0,0 +1,15 @@ +/** + * Returns an item to insert at the index, or `undefined` if an item shouldn't be inserted. + */ +type PickAlgorithm = ( + /** Elligible candidates to pick. */ + items: readonly D[], + /** Current iteration by which an item may be chosen. */ + iteration: number, + /** Implementation-specific opts. */ + opts: Record +) => D | undefined; + +export { + PickAlgorithm, +}; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/mentions_modal.tsx b/app/soapbox/features/ui/components/mentions_modal.tsx index 445858843..0cd104cfb 100644 --- a/app/soapbox/features/ui/components/mentions_modal.tsx +++ b/app/soapbox/features/ui/components/mentions_modal.tsx @@ -1,5 +1,5 @@ import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { FormattedMessage } from 'react-intl'; import { fetchStatusWithContext } from 'soapbox/actions/statuses'; @@ -9,8 +9,6 @@ import AccountContainer from 'soapbox/containers/account_container'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { makeGetStatus } from 'soapbox/selectors'; -const getStatus = makeGetStatus(); - interface IMentionsModal { onClose: (type: string) => void, statusId: string, @@ -18,6 +16,7 @@ interface IMentionsModal { const MentionsModal: React.FC = ({ onClose, statusId }) => { const dispatch = useAppDispatch(); + const getStatus = useCallback(makeGetStatus(), []); const status = useAppSelector((state) => getStatus(state, { id: statusId })); const accountIds = status ? ImmutableOrderedSet(status.mentions.map(m => m.get('id'))) : null; diff --git a/app/soapbox/features/ui/components/modal_root.js b/app/soapbox/features/ui/components/modal_root.js index f7b9b007c..2c2de0530 100644 --- a/app/soapbox/features/ui/components/modal_root.js +++ b/app/soapbox/features/ui/components/modal_root.js @@ -32,6 +32,7 @@ import { CompareHistoryModal, VerifySmsModal, FamiliarFollowersModal, + AccountModerationModal, } from 'soapbox/features/ui/util/async-components'; import BundleContainer from '../containers/bundle_container'; @@ -69,6 +70,7 @@ const MODAL_COMPONENTS = { 'COMPARE_HISTORY': CompareHistoryModal, 'VERIFY_SMS': VerifySmsModal, 'FAMILIAR_FOLLOWERS': FamiliarFollowersModal, + 'ACCOUNT_MODERATION': AccountModerationModal, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx new file mode 100644 index 000000000..8f91e1d3c --- /dev/null +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/account-moderation-modal.tsx @@ -0,0 +1,190 @@ +import React, { ChangeEventHandler, useState } from 'react'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import { + verifyUser, + unverifyUser, + suggestUsers, + unsuggestUsers, + setBadges as saveBadges, +} from 'soapbox/actions/admin'; +import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; +import snackbar from 'soapbox/actions/snackbar'; +import Account from 'soapbox/components/account'; +import List, { ListItem } from 'soapbox/components/list'; +import MissingIndicator from 'soapbox/components/missing_indicator'; +import { Button, Text, HStack, Modal, Stack, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount } from 'soapbox/hooks'; +import { makeGetAccount } from 'soapbox/selectors'; +import { isLocal } from 'soapbox/utils/accounts'; +import { getBadges } from 'soapbox/utils/badges'; + +import BadgeInput from './badge-input'; +import StaffRolePicker from './staff-role-picker'; + +const getAccount = makeGetAccount(); + +const messages = defineMessages({ + 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' }, + userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' }, + userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' }, + badgesSaved: { id: 'admin.users.badges_saved_message', defaultMessage: 'Custom badges updated.' }, +}); + +interface IAccountModerationModal { + /** Action to close the modal. */ + onClose: (type: string) => void, + /** ID of the account to moderate. */ + accountId: string, +} + +/** Moderator actions against accounts. */ +const AccountModerationModal: React.FC = ({ onClose, accountId }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const ownAccount = useOwnAccount(); + const features = useFeatures(); + const account = useAppSelector(state => getAccount(state, accountId)); + + const accountBadges = account ? getBadges(account) : []; + const [badges, setBadges] = useState(accountBadges); + + const handleClose = () => onClose('ACCOUNT_MODERATION'); + + if (!account || !ownAccount) { + return ( + + + + ); + } + + const handleAdminFE = () => { + window.open(`/pleroma/admin/#/users/${account.id}/`, '_blank'); + }; + + const handleVerifiedChange: ChangeEventHandler = (e) => { + const { checked } = e.target; + + const message = checked ? messages.userVerified : messages.userUnverified; + const action = checked ? verifyUser : unverifyUser; + + dispatch(action(account.id)) + .then(() => dispatch(snackbar.success(intl.formatMessage(message, { acct: account.acct })))) + .catch(() => {}); + }; + + const handleSuggestedChange: ChangeEventHandler = (e) => { + const { checked } = e.target; + + const message = checked ? messages.userSuggested : messages.userUnsuggested; + const action = checked ? suggestUsers : unsuggestUsers; + + dispatch(action([account.id])) + .then(() => dispatch(snackbar.success(intl.formatMessage(message, { acct: account.acct })))) + .catch(() => {}); + }; + + const handleDeactivate = () => { + dispatch(deactivateUserModal(intl, account.id)); + }; + + const handleDelete = () => { + dispatch(deleteUserModal(intl, account.id)); + }; + + const handleSaveBadges = () => { + dispatch(saveBadges(account.id, accountBadges, badges)) + .then(() => dispatch(snackbar.success(intl.formatMessage(messages.badgesSaved)))) + .catch(() => {}); + }; + + return ( + } + onClose={handleClose} + > + +
+ +
+ + + {(ownAccount.admin && isLocal(account)) && ( + }> +
+ +
+
+ )} + + }> + + + + {features.suggestionsV2 && ( + }> + + + )} + + }> +
+ + + + +
+
+
+ + + } + onClick={handleDeactivate} + /> + + } + onClick={handleDelete} + /> + + + + + + + {features.adminFE && ( + + + + )} +
+
+ ); +}; + +export default AccountModerationModal; diff --git a/app/soapbox/features/ui/components/modals/account-moderation-modal/badge-input.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/badge-input.tsx new file mode 100644 index 000000000..63ef22fad --- /dev/null +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/badge-input.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useIntl, defineMessages } from 'react-intl'; + +import { TagInput } from 'soapbox/components/ui'; +import { badgeToTag, tagToBadge } from 'soapbox/utils/badges'; + +const messages = defineMessages({ + placeholder: { id: 'badge_input.placeholder', defaultMessage: 'Enter a badge…' }, +}); + +interface IBadgeInput { + /** A badge is a tag that begins with `badge:` */ + badges: string[], + /** Callback when badges change. */ + onChange: (badges: string[]) => void, +} + +/** Manages user badges. */ +const BadgeInput: React.FC = ({ badges, onChange }) => { + const intl = useIntl(); + const tags = badges.map(badgeToTag); + + const handleTagsChange = (tags: string[]) => { + const badges = tags.map(tagToBadge); + onChange(badges); + }; + + return ( + + ); +}; + +export default BadgeInput; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/modals/account-moderation-modal/staff-role-picker.tsx b/app/soapbox/features/ui/components/modals/account-moderation-modal/staff-role-picker.tsx new file mode 100644 index 000000000..30502d427 --- /dev/null +++ b/app/soapbox/features/ui/components/modals/account-moderation-modal/staff-role-picker.tsx @@ -0,0 +1,86 @@ +import React, { useMemo } from 'react'; +import { defineMessages, MessageDescriptor, useIntl } from 'react-intl'; + +import { setRole } from 'soapbox/actions/admin'; +import snackbar from 'soapbox/actions/snackbar'; +import { SelectDropdown } from 'soapbox/features/forms'; +import { useAppDispatch } from 'soapbox/hooks'; + +import type { Account as AccountEntity } from 'soapbox/types/entities'; + +/** Staff role. */ +type AccountRole = 'user' | 'moderator' | 'admin'; + +/** Get the highest staff role associated with the account. */ +const getRole = (account: AccountEntity): AccountRole => { + if (account.admin) { + return 'admin'; + } else if (account.moderator) { + return 'moderator'; + } else { + return 'user'; + } +}; + +const messages = defineMessages({ + roleUser: { id: 'account_moderation_modal.roles.user', defaultMessage: 'User' }, + roleModerator: { id: 'account_moderation_modal.roles.moderator', defaultMessage: 'Moderator' }, + roleAdmin: { id: 'account_moderation_modal.roles.admin', defaultMessage: 'Admin' }, + 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' }, +}); + +interface IStaffRolePicker { + /** Account whose role to change. */ + account: AccountEntity, +} + +/** Picker for setting the staff role of an account. */ +const StaffRolePicker: React.FC = ({ account }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const roles: Record = useMemo(() => ({ + user: intl.formatMessage(messages.roleUser), + moderator: intl.formatMessage(messages.roleModerator), + admin: intl.formatMessage(messages.roleAdmin), + }), []); + + const handleRoleChange: React.ChangeEventHandler = (e) => { + const role = e.target.value as AccountRole; + + dispatch(setRole(account.id, role)) + .then(() => { + let message: MessageDescriptor | undefined; + + if (role === 'admin') { + message = messages.promotedToAdmin; + } else if (role === 'moderator' && account.admin) { + message = messages.demotedToModerator; + } else if (role === 'moderator') { + message = messages.promotedToModerator; + } else if (role === 'user') { + message = messages.demotedToUser; + } + + if (message) { + dispatch(snackbar.success(intl.formatMessage(message, { acct: account.acct }))); + } + }) + .catch(() => {}); + }; + + const accountRole = getRole(account); + + return ( + + ); +}; + +export default StaffRolePicker; \ No newline at end of file diff --git a/app/soapbox/features/ui/components/profile_info_panel.tsx b/app/soapbox/features/ui/components/profile_info_panel.tsx index 2c0a9163b..c6c6d084e 100644 --- a/app/soapbox/features/ui/components/profile_info_panel.tsx +++ b/app/soapbox/features/ui/components/profile_info_panel.tsx @@ -8,6 +8,8 @@ import { Icon, HStack, Stack, Text } from 'soapbox/components/ui'; import VerificationBadge from 'soapbox/components/verification_badge'; import { useSoapboxConfig } from 'soapbox/hooks'; import { isLocal } from 'soapbox/utils/accounts'; +import { badgeToTag, getBadges as getAccountBadges } from 'soapbox/utils/badges'; +import { capitalize } from 'soapbox/utils/strings'; import ProfileFamiliarFollowers from './profile_familiar_followers'; import ProfileStats from './profile_stats'; @@ -52,7 +54,20 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => } }; + const getCustomBadges = (): React.ReactNode[] => { + const badges = getAccountBadges(account); + + return badges.map(badge => ( + + )); + }; + const getBadges = (): React.ReactNode[] => { + const custom = getCustomBadges(); const staffBadge = getStaffBadge(); const isPatron = account.getIn(['patron', 'is_patron']) === true; @@ -66,11 +81,7 @@ const ProfileInfoPanel: React.FC = ({ account, username }) => badges.push(); } - if (account.donor) { - badges.push(); - } - - return badges; + return [...badges, ...custom]; }; const renderBirthday = (): React.ReactNode => { diff --git a/app/soapbox/features/ui/components/timeline.tsx b/app/soapbox/features/ui/components/timeline.tsx index 53d1ad7f8..38c5a4341 100644 --- a/app/soapbox/features/ui/components/timeline.tsx +++ b/app/soapbox/features/ui/components/timeline.tsx @@ -25,7 +25,7 @@ const Timeline: React.FC = ({ ...rest }) => { const dispatch = useAppDispatch(); - const getStatusIds = useCallback(makeGetStatusIds, [])(); + const getStatusIds = useCallback(makeGetStatusIds(), []); const lastStatusId = useAppSelector(state => (state.timelines.get(timelineId)?.items || ImmutableOrderedSet()).last() as string | undefined); const statusIds = useAppSelector(state => getStatusIds(state, { type: timelineId })); diff --git a/app/soapbox/features/ui/util/async-components.ts b/app/soapbox/features/ui/util/async-components.ts index 43b5c16a1..9f1826fe8 100644 --- a/app/soapbox/features/ui/util/async-components.ts +++ b/app/soapbox/features/ui/util/async-components.ts @@ -106,6 +106,10 @@ export function ReportModal() { return import(/* webpackChunkName: "modals/report-modal/report-modal" */'../components/modals/report-modal/report-modal'); } +export function AccountModerationModal() { + return import(/* webpackChunkName: "modals/account-moderation-modal" */'../components/modals/account-moderation-modal/account-moderation-modal'); +} + export function MediaGallery() { return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery'); } diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js index e5eb400b7..1d6b55ff6 100644 --- a/app/soapbox/features/video/index.js +++ b/app/soapbox/features/video/index.js @@ -497,7 +497,7 @@ class Video extends React.PureComponent { } render() { - const { src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio, blurhash } = this.props; + const { src, inline, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio, blurhash } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; const playerStyle = {}; @@ -614,8 +614,6 @@ class Video extends React.PureComponent {
{(sensitive && !onCloseVideo) && } - {(!fullscreen && onOpenVideo) && } - {/* onCloseVideo && */}
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index cded97f64..7ce350976 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -974,7 +974,7 @@ "soapbox_config.single_user_mode_profile_label": "Main user handle", "soapbox_config.verified_can_edit_name_label": "Allow verified users to edit their own display name.", "status.actions.more": "More", - "status.admin_account": "Open moderation interface for @{name}", + "status.admin_account": "Moderate @{name}", "status.admin_status": "Open this post in the moderation interface", "status.block": "Block @{name}", "status.bookmark": "Bookmark", diff --git a/app/soapbox/normalizers/account.ts b/app/soapbox/normalizers/account.ts index 37f42ab8f..46d2a632f 100644 --- a/app/soapbox/normalizers/account.ts +++ b/app/soapbox/normalizers/account.ts @@ -56,7 +56,6 @@ export const AccountRecord = ImmutableRecord({ admin: false, display_name_html: '', domain: '', - donor: false, moderator: false, note_emojified: '', note_plain: '', @@ -155,9 +154,11 @@ const normalizeVerified = (account: ImmutableMap) => { }); }; -/** Get donor status from tags. */ +/** Upgrade legacy donor tag to a badge. */ const normalizeDonor = (account: ImmutableMap) => { - return account.set('donor', getTags(account).includes('donor')); + const tags = getTags(account); + const updated = tags.includes('donor') ? tags.push('badge:donor') : tags; + return account.setIn(['pleroma', 'tags'], updated); }; /** Normalize Fedibird/Truth Social/Pleroma location */ diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index a471401c5..0e6b5c280 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -175,6 +175,19 @@ const normalizeFooterLinks = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap return soapboxConfig.setIn(path, items); }; +/** Migrate legacy ads config. */ +const normalizeAdsAlgorithm = (soapboxConfig: SoapboxConfigMap): SoapboxConfigMap => { + const interval = soapboxConfig.getIn(['extensions', 'ads', 'interval']); + const algorithm = soapboxConfig.getIn(['extensions', 'ads', 'algorithm']); + + if (typeof interval === 'number' && !algorithm) { + const result = fromJS(['linear', { interval }]); + return soapboxConfig.setIn(['extensions', 'ads', 'algorithm'], result); + } else { + return soapboxConfig; + } +}; + export const normalizeSoapboxConfig = (soapboxConfig: Record) => { return SoapboxConfigRecord( ImmutableMap(fromJS(soapboxConfig)).withMutations(soapboxConfig => { @@ -186,6 +199,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record) => { maybeAddMissingColors(soapboxConfig); normalizeCryptoAddresses(soapboxConfig); normalizeAds(soapboxConfig); + normalizeAdsAlgorithm(soapboxConfig); }), ); }; diff --git a/app/soapbox/reducers/search.ts b/app/soapbox/reducers/search.ts index c73dbf865..693a4a388 100644 --- a/app/soapbox/reducers/search.ts +++ b/app/soapbox/reducers/search.ts @@ -83,7 +83,7 @@ const paginateResults = (state: State, searchType: SearchFilter, results: APIEnt const data = results[searchType]; // Hashtags are a list of maps. Others are IDs. if (searchType === 'hashtags') { - return (items as ImmutableOrderedSet).concat(fromJS(data)); + return (items as ImmutableOrderedSet).concat((fromJS(data) as Record).map(normalizeTag)); } else { return (items as ImmutableOrderedSet).concat(toIds(data)); } diff --git a/app/soapbox/reducers/user_lists.ts b/app/soapbox/reducers/user_lists.ts index fc86cceb2..38017f0bb 100644 --- a/app/soapbox/reducers/user_lists.ts +++ b/app/soapbox/reducers/user_lists.ts @@ -98,7 +98,6 @@ type NestedListPath = ['followers' | 'following' | 'reblogged_by' | 'favourited_ type ListPath = ['follow_requests' | 'blocks' | 'mutes' | 'directory']; const normalizeList = (state: State, path: NestedListPath | ListPath, accounts: APIEntity[], next?: string | null) => { - return state.setIn(path, ListRecord({ next, items: ImmutableOrderedSet(accounts.map(item => item.id)), diff --git a/app/soapbox/utils/__tests__/badges.test.ts b/app/soapbox/utils/__tests__/badges.test.ts new file mode 100644 index 000000000..1f3349fcc --- /dev/null +++ b/app/soapbox/utils/__tests__/badges.test.ts @@ -0,0 +1,43 @@ +import { normalizeAccount } from 'soapbox/normalizers'; + +import { + tagToBadge, + badgeToTag, + filterBadges, + getTagDiff, + getBadges, +} from '../badges'; + +import type { Account } from 'soapbox/types/entities'; + +test('tagToBadge', () => { + expect(tagToBadge('yolo')).toEqual('badge:yolo'); +}); + +test('badgeToTag', () => { + expect(badgeToTag('badge:yolo')).toEqual('yolo'); + expect(badgeToTag('badge:badge:')).toEqual('badge:'); +}); + +test('filterBadges', () => { + const tags = ['one', 'badge:two', 'badge:three', 'four']; + const badges = ['badge:two', 'badge:three']; + expect(filterBadges(tags)).toEqual(badges); +}); + +test('getTagDiff', () => { + const oldTags = ['one', 'two', 'three']; + const newTags = ['three', 'four', 'five']; + + const expected = { + added: ['four', 'five'], + removed: ['one', 'two'], + }; + + expect(getTagDiff(oldTags, newTags)).toEqual(expected); +}); + +test('getBadges', () => { + const account = normalizeAccount({ id: '1', pleroma: { tags: ['a', 'b', 'badge:c'] } }) as Account; + expect(getBadges(account)).toEqual(['badge:c']); +}); \ No newline at end of file diff --git a/app/soapbox/utils/accounts.ts b/app/soapbox/utils/accounts.ts index b2ed45d39..3a1cadbe9 100644 --- a/app/soapbox/utils/accounts.ts +++ b/app/soapbox/utils/accounts.ts @@ -1,5 +1,3 @@ -import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; - import type { Account } from 'soapbox/types/entities'; const getDomainFromURL = (account: Account): string => { @@ -28,12 +26,6 @@ export const getAcct = (account: Account, displayFqn: boolean): string => ( displayFqn === true ? account.fqn : account.acct ); -export const getFollowDifference = (state: ImmutableMap, accountId: string, type: string): number => { - const items: any = state.getIn(['user_lists', type, accountId, 'items'], ImmutableOrderedSet()); - const counter: number = Number(state.getIn(['accounts_counters', accountId, `${type}_count`], 0)); - return Math.max(counter - items.size, 0); -}; - export const isLocal = (account: Account): boolean => { const domain: string = account.acct.split('@')[1]; return domain === undefined ? true : false; diff --git a/app/soapbox/utils/badges.ts b/app/soapbox/utils/badges.ts new file mode 100644 index 000000000..139537f6f --- /dev/null +++ b/app/soapbox/utils/badges.ts @@ -0,0 +1,47 @@ +import { OrderedSet as ImmutableOrderedSet } from 'immutable'; + +import type { Account } from 'soapbox/types/entities'; + +/** Convert a plain tag into a badge. */ +const tagToBadge = (tag: string) => `badge:${tag}`; + +/** Convert a badge into a plain tag. */ +const badgeToTag = (badge: string) => badge.replace(/^badge:/, ''); + +/** Difference between an old and new set of tags. */ +interface TagDiff { + /** New tags that were added. */ + added: string[], + /** Old tags that were removed. */ + removed: string[], +} + +/** Returns the differences between two sets of tags. */ +const getTagDiff = (oldTags: string[], newTags: string[]): TagDiff => { + const o = ImmutableOrderedSet(oldTags); + const n = ImmutableOrderedSet(newTags); + + return { + added: n.subtract(o).toArray(), + removed: o.subtract(n).toArray(), + }; +}; + +/** Returns only tags which are badges. */ +const filterBadges = (tags: string[]): string[] => { + return tags.filter(tag => tag.startsWith('badge:')); +}; + +/** Get badges from an account. */ +const getBadges = (account: Account) => { + const tags = Array.from(account?.getIn(['pleroma', 'tags']) as Iterable || []); + return filterBadges(tags); +}; + +export { + tagToBadge, + badgeToTag, + filterBadges, + getTagDiff, + getBadges, +}; \ No newline at end of file diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 78908b04f..f220f5e67 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -142,6 +142,12 @@ const getInstanceFeatures = (instance: Instance) => { */ accountWebsite: v.software === TRUTHSOCIAL, + /** + * An additional moderator interface is available on the domain. + * @see /pleroma/admin + */ + adminFE: v.software === PLEROMA, + /** * Can display announcements set by admins. * @see GET /api/v1/announcements diff --git a/app/styles/basics.scss b/app/styles/basics.scss index 9db26912a..9384c94f2 100644 --- a/app/styles/basics.scss +++ b/app/styles/basics.scss @@ -112,3 +112,20 @@ noscript { div[data-viewport-type="window"] { position: static !important; } + +// Instance HTML from the API. +.instance-description { + a { + @apply underline; + } + + b, + strong { + @apply font-bold; + } + + i, + em { + @apply italic; + } +} diff --git a/package.json b/package.json index eef743978..1ba60028b 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@types/react-swipeable-views": "^0.13.1", "@types/react-toggle": "^4.0.3", "@types/redux-mock-store": "^1.0.3", + "@types/seedrandom": "^3.0.2", "@types/semver": "^7.3.9", "@types/uuid": "^8.3.4", "array-includes": "^3.1.5", @@ -184,6 +185,7 @@ "resize-observer-polyfill": "^1.5.1", "sass": "^1.20.3", "sass-loader": "^13.0.0", + "seedrandom": "^3.0.5", "semver": "^7.3.2", "stringz": "^2.0.0", "substring-trie": "^1.0.2", diff --git a/yarn.lock b/yarn.lock index 1e2044863..a3cbcff2e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,11 @@ dependencies: schema-utils "*" +"@types/seedrandom@^3.0.2": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-3.0.2.tgz#7f30db28221067a90b02e73ffd46b6685b18df1a" + integrity sha512-YPLqEOo0/X8JU3rdiq+RgUKtQhQtrppE766y7vMTu8dGML7TVtZNiiiaC/hhU9Zqw9UYopXxhuWWENclMVBwKQ== + "@types/semver@^7.3.9": version "7.3.9" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.9.tgz#152c6c20a7688c30b967ec1841d31ace569863fc" @@ -10461,6 +10466,11 @@ scroll-behavior@^0.9.1: dom-helpers "^3.4.0" invariant "^2.2.4" +seedrandom@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-3.0.5.tgz#54edc85c95222525b0c7a6f6b3543d8e0b3aa0a7" + integrity sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"