Merge remote-tracking branch 'soapbox/develop' into compose
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
commit
cb3df8211c
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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<IBadge> = ({ title, slug }) => (
|
||||
<span
|
||||
data-testid='badge'
|
||||
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
|
||||
'bg-fuchsia-700 text-white': slug === 'patron',
|
||||
'bg-yellow-500 text-white': slug === 'donor',
|
||||
'bg-black text-white': slug === 'admin',
|
||||
'bg-cyan-600 text-white': slug === 'moderator',
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': slug === 'bot',
|
||||
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
const Badge: React.FC<IBadge> = ({ title, slug }) => {
|
||||
const fallback = !['patron', 'admin', 'moderator', 'opaque', 'badge:donor'].includes(slug);
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid='badge'
|
||||
className={classNames('inline-flex items-center px-2 py-0.5 rounded text-xs font-medium', {
|
||||
'bg-fuchsia-700 text-white': slug === 'patron',
|
||||
'bg-emerald-800 text-white': slug === 'badge:donor',
|
||||
'bg-black text-white': slug === 'admin',
|
||||
'bg-cyan-600 text-white': slug === 'moderator',
|
||||
'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100': fallback,
|
||||
'bg-white bg-opacity-75 text-gray-900': slug === 'opaque',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
|
|
|
@ -34,7 +34,7 @@ const ListItem: React.FC<IListItem> = ({ label, hint, children, onClick }) => {
|
|||
id: domId,
|
||||
className: classNames({
|
||||
'w-auto': isSelect,
|
||||
}),
|
||||
}, child.props.className),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -37,10 +37,6 @@ const getBadges = (account: Account): JSX.Element[] => {
|
|||
badges.push(<Badge key='patron' slug='patron' title='Patron' />);
|
||||
}
|
||||
|
||||
if (account.donor) {
|
||||
badges.push(<Badge key='donor' slug='donor' title='Donor' />);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
|
|
|
@ -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<AccountEntity> = 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) && (
|
||||
<SidebarLink
|
||||
to='/follow_requests'
|
||||
icon={require('@tabler/icons/user-plus.svg')}
|
||||
text={intl.formatMessage(messages.followRequests)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.bookmarks && (
|
||||
<SidebarLink
|
||||
to='/bookmarks'
|
||||
|
|
|
@ -10,7 +10,7 @@ import { launchChat } from 'soapbox/actions/chats';
|
|||
import { directCompose, mentionCompose, quoteCompose, replyCompose } from 'soapbox/actions/compose';
|
||||
import { toggleBookmark, toggleFavourite, togglePin, toggleReblog } from 'soapbox/actions/interactions';
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { deactivateUserModal, deleteStatusModal, deleteUserModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions/moderation';
|
||||
import { initMuteModal } from 'soapbox/actions/mutes';
|
||||
import { initReport } from 'soapbox/actions/reports';
|
||||
import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses';
|
||||
|
@ -51,7 +51,7 @@ const messages = defineMessages({
|
|||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
adminAccount: { id: 'status.admin_account', defaultMessage: 'Moderate @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
||||
group_remove_account: { id: 'status.remove_account_from_group', defaultMessage: 'Remove account from group' },
|
||||
|
@ -299,14 +299,10 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleDeactivateUser: React.EventHandler<React.MouseEvent> = (e) => {
|
||||
const onModerate: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
dispatch(deactivateUserModal(intl, status.getIn(['account', 'id']) as string));
|
||||
};
|
||||
|
||||
const handleDeleteUser: React.EventHandler<React.MouseEvent> = (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<React.MouseEvent> = (e) => {
|
||||
|
@ -452,13 +448,13 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
|
|||
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<IStatusActionBar> = ({
|
|||
});
|
||||
|
||||
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,
|
||||
|
|
|
@ -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<IStatusList> = ({
|
|||
}) => {
|
||||
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<string, any>).toJS();
|
||||
|
||||
const node = useRef<VirtuosoHandle>(null);
|
||||
const seed = useRef<string>(uuidv4());
|
||||
|
||||
const getFeaturedStatusCount = () => {
|
||||
return featuredStatusIds?.size || 0;
|
||||
|
@ -132,9 +139,10 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const renderAd = (ad: AdEntity) => {
|
||||
const renderAd = (ad: AdEntity, index: number) => {
|
||||
return (
|
||||
<Ad
|
||||
key={`ad-${index}`}
|
||||
card={ad.card}
|
||||
impression={ad.impression}
|
||||
expires={ad.expires}
|
||||
|
@ -175,9 +183,13 @@ const StatusList: React.FC<IStatusList> = ({
|
|||
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<IStatusList> = ({
|
|||
acc.push(renderStatus(statusId));
|
||||
}
|
||||
|
||||
if (showAds && ad && showAd) {
|
||||
acc.push(renderAd(ad));
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as React.ReactNode[]);
|
||||
} else {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<ITagInput> = ({ 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 (
|
||||
<div className='mt-1 relative shadow-sm flex-grow'>
|
||||
<HStack
|
||||
className='p-2 pb-0 text-gray-900 dark:text-gray-100 placeholder:text-gray-600 dark:placeholder:text-gray-600 block w-full sm:text-sm dark:ring-1 dark:ring-gray-800 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-500 dark:focus:border-primary-500 rounded-md bg-white dark:bg-gray-900 border-gray-400 dark:border-gray-800'
|
||||
space={2}
|
||||
wrap
|
||||
>
|
||||
{tags.map((tag, i) => (
|
||||
<div className='mb-2'>
|
||||
<Tag tag={tag} onDelete={handleTagDelete} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<input
|
||||
className='p-1 mb-2 w-32 h-8 flex-grow bg-transparent outline-none'
|
||||
value={input}
|
||||
placeholder={placeholder}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagInput;
|
|
@ -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<ITag> = ({ tag, onDelete }) => {
|
||||
return (
|
||||
<div className='inline-flex p-1 rounded bg-primary-500 items-center whitespace-nowrap'>
|
||||
<Text theme='white'>{tag}</Text>
|
||||
|
||||
<IconButton
|
||||
iconClassName='w-4 h-4'
|
||||
src={require('@tabler/icons/x.svg')}
|
||||
onClick={() => onDelete(tag)}
|
||||
transparent
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tag;
|
|
@ -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<IStatus, 'status'> {
|
|||
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<IStatusContainer> = (props) => {
|
||||
const { id, ...rest } = props;
|
||||
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
const status = useAppSelector(state => getStatus(state, { id }));
|
||||
|
||||
if (status) {
|
||||
|
|
|
@ -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<IHeader> = ({ 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<IHeader> = ({ 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;
|
||||
|
|
|
@ -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<IReport> = ({ report }) => {
|
||||
const Report: React.FC<IReport> = ({ 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;
|
||||
|
||||
|
|
|
@ -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<IUnapprovedAccount> = ({ 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));
|
||||
|
|
|
@ -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 && <Report report={report} key={report?.id} />)}
|
||||
{reports.map(report => report && <Report id={report} key={report} />)}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<string>
|
||||
|
@ -25,6 +23,8 @@ interface IAccount {
|
|||
const Account: React.FC<IAccount> = ({ accountId, aliases }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
const added = useAppSelector((state) => {
|
||||
|
|
|
@ -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<IAccount> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
// useEffect(() => {
|
||||
|
@ -30,7 +30,7 @@ const Account: React.FC<IAccount> = ({ 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<IAccount> = ({ accountId }) => {
|
|||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<Permalink className='account__display-name' title={account.get('acct')} href={`/@${account.get('acct')}`} to={`/@${account.get('acct')}`}>
|
||||
<Permalink className='account__display-name' title={account.acct} href={`/@${account.acct}`} to={`/@${account.acct}`}>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
|
|
|
@ -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<IChat> = ({ 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;
|
||||
|
|
|
@ -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<IAutosuggestAccount> = ({ id }) => {
|
||||
const getAccount = makeGetAccount();
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
const account = useAppSelector((state) => getAccount(state, id));
|
||||
|
||||
if (!account) return null;
|
||||
|
|
|
@ -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<IReplyMentions> = ({ composeId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: compose.in_reply_to! }));
|
||||
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
|
||||
const to = compose.to;
|
||||
const account = useAppSelector((state) => state.accounts.get(state.me));
|
||||
|
||||
|
|
|
@ -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<IQuotedStatusContainer> = ({ composeId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
|
||||
|
||||
const onCancel = () => {
|
||||
|
|
|
@ -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<IEmbeddedStatus> = ({ 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);
|
||||
|
|
|
@ -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 (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (accountId === -1) {
|
||||
return (
|
||||
<Column>
|
||||
<Spinner />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<Column>
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = isMyAccount
|
||||
? <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any liked posts yet. When you like one, it will show up here." />
|
||||
: <FormattedMessage id='empty_column.account_favourited_statuses' defaultMessage="This user doesn't have any liked posts yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader={false} transparent>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='favourited_statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={typeof isLoading === 'boolean' ? isLoading : true}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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<IFavourites> = (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<string>());
|
||||
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 (
|
||||
<Column>
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = isOwnAccount
|
||||
? <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any liked posts yet. When you like one, it will show up here." />
|
||||
: <FormattedMessage id='empty_column.account_favourited_statuses' defaultMessage="This user doesn't have any liked posts yet." />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader={false} transparent>
|
||||
<StatusList
|
||||
statusIds={statusIds}
|
||||
scrollKey='favourited_statuses'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Favourites;
|
|
@ -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<IAccountAuthorize> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, id));
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (accountId === -1 || (!accountIds)) {
|
||||
return (
|
||||
<Spinner />
|
||||
);
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<ScrollableList
|
||||
scrollKey='followers'
|
||||
hasMore={hasMore}
|
||||
diffCount={diffCount}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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<IFollowers> = (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<string>());
|
||||
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 (
|
||||
<Spinner />
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (isUnavailable) {
|
||||
return (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<ScrollableList
|
||||
scrollKey='followers'
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />}
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Followers;
|
|
@ -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 (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (accountId === -1 || (!accountIds)) {
|
||||
return (
|
||||
<Spinner />
|
||||
);
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<ScrollableList
|
||||
scrollKey='following'
|
||||
hasMore={hasMore}
|
||||
diffCount={diffCount}
|
||||
onLoadMore={this.handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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<IFollowing> = (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<string>());
|
||||
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 (
|
||||
<Spinner />
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<MissingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (isUnavailable) {
|
||||
return (
|
||||
<div className='empty-column-indicator'>
|
||||
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} transparent>
|
||||
<ScrollableList
|
||||
scrollKey='following'
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={<FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />}
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export default Following;
|
|
@ -113,7 +113,10 @@ const LandingPage = () => {
|
|||
</h1>
|
||||
|
||||
<Text size='lg'>
|
||||
{instance.description}
|
||||
<span
|
||||
className='instance-description'
|
||||
dangerouslySetInnerHTML={{ __html: instance.short_description || instance.description }}
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
|
|
@ -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<IAccount> = ({ accountId }) => {
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
|
|
@ -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<IAccount> = ({ 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));
|
||||
|
|
|
@ -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<INotificaton> = (props) => {
|
|||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getNotification = useCallback(makeGetNotification(), []);
|
||||
|
||||
const notification = useAppSelector((state) => getNotification(state, props.notification));
|
||||
|
||||
const history = useHistory();
|
||||
|
|
|
@ -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<IAccount> = ({ composeId, accountId, author }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const getAccount = useCallback(makeGetAccount(), []);
|
||||
|
||||
const compose = useCompose(composeId);
|
||||
|
||||
|
|
|
@ -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));
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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<IQuotedStatusContainer> = ({ statusId }) => {
|
||||
const getStatus = useCallback(makeGetStatus(), []);
|
||||
|
||||
const status = useAppSelector(state => getStatus(state, { id: statusId }));
|
||||
|
||||
if (!status) {
|
||||
|
|
|
@ -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<IThread> = (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 }));
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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');
|
||||
});
|
|
@ -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<any, unknown>;
|
||||
|
||||
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,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { abovefoldAlgorithm } from './abovefold';
|
||||
import { linearAlgorithm } from './linear';
|
||||
|
||||
import type { PickAlgorithm } from './types';
|
||||
|
||||
const ALGORITHMS: Record<any, PickAlgorithm | undefined> = {
|
||||
'linear': linearAlgorithm,
|
||||
'abovefold': abovefoldAlgorithm,
|
||||
};
|
||||
|
||||
export { ALGORITHMS };
|
|
@ -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<any, unknown>;
|
||||
|
||||
return {
|
||||
interval: typeof interval === 'number' ? interval : 20,
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
linearAlgorithm,
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Returns an item to insert at the index, or `undefined` if an item shouldn't be inserted.
|
||||
*/
|
||||
type PickAlgorithm = <D = any>(
|
||||
/** Elligible candidates to pick. */
|
||||
items: readonly D[],
|
||||
/** Current iteration by which an item may be chosen. */
|
||||
iteration: number,
|
||||
/** Implementation-specific opts. */
|
||||
opts: Record<string, unknown>
|
||||
) => D | undefined;
|
||||
|
||||
export {
|
||||
PickAlgorithm,
|
||||
};
|
|
@ -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<IMentionsModal> = ({ 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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<IAccountModerationModal> = ({ 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<string[]>(accountBadges);
|
||||
|
||||
const handleClose = () => onClose('ACCOUNT_MODERATION');
|
||||
|
||||
if (!account || !ownAccount) {
|
||||
return (
|
||||
<Modal onClose={handleClose}>
|
||||
<MissingIndicator />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const handleAdminFE = () => {
|
||||
window.open(`/pleroma/admin/#/users/${account.id}/`, '_blank');
|
||||
};
|
||||
|
||||
const handleVerifiedChange: ChangeEventHandler<HTMLInputElement> = (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<HTMLInputElement> = (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 (
|
||||
<Modal
|
||||
title={<FormattedMessage id='account_moderation_modal.title' defaultMessage='Moderate @{acct}' values={{ acct: account.acct }} />}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Stack space={4}>
|
||||
<div className='p-4 rounded-lg border border-solid border-gray-300 dark:border-gray-800'>
|
||||
<Account
|
||||
account={account}
|
||||
showProfileHoverCard={false}
|
||||
withLinkToProfile={false}
|
||||
hideActions
|
||||
/>
|
||||
</div>
|
||||
|
||||
<List>
|
||||
{(ownAccount.admin && isLocal(account)) && (
|
||||
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.account_role' defaultMessage='Staff level' />}>
|
||||
<div className='w-auto'>
|
||||
<StaffRolePicker account={account} />
|
||||
</div>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.verified' defaultMessage='Verified account' />}>
|
||||
<Toggle
|
||||
checked={account.verified}
|
||||
onChange={handleVerifiedChange}
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
{features.suggestionsV2 && (
|
||||
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.suggested' defaultMessage='Suggested in people to follow' />}>
|
||||
<Toggle
|
||||
checked={account.getIn(['pleroma', 'is_suggested']) === true}
|
||||
onChange={handleSuggestedChange}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
|
||||
<ListItem label={<FormattedMessage id='account_moderation_modal.fields.badges' defaultMessage='Custom badges' />}>
|
||||
<div className='flex-grow'>
|
||||
<HStack className='w-full' alignItems='center' space={2}>
|
||||
<BadgeInput badges={badges} onChange={setBadges} />
|
||||
<Button onClick={handleSaveBadges}>
|
||||
<FormattedMessage id='save' defaultMessage='Save' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<List>
|
||||
<ListItem
|
||||
label={<FormattedMessage id='account_moderation_modal.fields.deactivate' defaultMessage='Deactivate account' />}
|
||||
onClick={handleDeactivate}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
label={<FormattedMessage id='account_moderation_modal.fields.delete' defaultMessage='Delete account' />}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</List>
|
||||
|
||||
<Text theme='subtle' size='xs'>
|
||||
<FormattedMessage
|
||||
id='account_moderation_modal.info.id'
|
||||
defaultMessage='ID: {id}'
|
||||
values={{ id: account.id }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{features.adminFE && (
|
||||
<HStack justifyContent='center'>
|
||||
<Button icon={require('@tabler/icons/external-link.svg')} size='sm' theme='secondary' onClick={handleAdminFE}>
|
||||
<FormattedMessage id='account_moderation_modal.admin_fe' defaultMessage='Open in AdminFE' />
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountModerationModal;
|
|
@ -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<IBadgeInput> = ({ badges, onChange }) => {
|
||||
const intl = useIntl();
|
||||
const tags = badges.map(badgeToTag);
|
||||
|
||||
const handleTagsChange = (tags: string[]) => {
|
||||
const badges = tags.map(tagToBadge);
|
||||
onChange(badges);
|
||||
};
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={handleTagsChange}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeInput;
|
|
@ -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<IStaffRolePicker> = ({ account }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const roles: Record<AccountRole, string> = useMemo(() => ({
|
||||
user: intl.formatMessage(messages.roleUser),
|
||||
moderator: intl.formatMessage(messages.roleModerator),
|
||||
admin: intl.formatMessage(messages.roleAdmin),
|
||||
}), []);
|
||||
|
||||
const handleRoleChange: React.ChangeEventHandler<HTMLSelectElement> = (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 (
|
||||
<SelectDropdown
|
||||
items={roles}
|
||||
defaultValue={accountRole}
|
||||
onChange={handleRoleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaffRolePicker;
|
|
@ -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<IProfileInfoPanel> = ({ account, username }) =>
|
|||
}
|
||||
};
|
||||
|
||||
const getCustomBadges = (): React.ReactNode[] => {
|
||||
const badges = getAccountBadges(account);
|
||||
|
||||
return badges.map(badge => (
|
||||
<Badge
|
||||
key={badge}
|
||||
slug={badge}
|
||||
title={capitalize(badgeToTag(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<IProfileInfoPanel> = ({ account, username }) =>
|
|||
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
|
||||
}
|
||||
|
||||
if (account.donor) {
|
||||
badges.push(<Badge slug='donor' title='Donor' key='donor' />);
|
||||
}
|
||||
|
||||
return badges;
|
||||
return [...badges, ...custom];
|
||||
};
|
||||
|
||||
const renderBirthday = (): React.ReactNode => {
|
||||
|
|
|
@ -25,7 +25,7 @@ const Timeline: React.FC<ITimeline> = ({
|
|||
...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 }));
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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 {
|
|||
|
||||
<div className='video-player__buttons right'>
|
||||
{(sensitive && !onCloseVideo) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon src={require('@tabler/icons/eye-off.svg')} /></button>}
|
||||
{(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon src={require('@tabler/icons/maximize.svg')} /></button>}
|
||||
{/* onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon src={require('@tabler/icons/x.svg')} /></button> */}
|
||||
<button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon src={fullscreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<string, any>) => {
|
|||
});
|
||||
};
|
||||
|
||||
/** Get donor status from tags. */
|
||||
/** Upgrade legacy donor tag to a badge. */
|
||||
const normalizeDonor = (account: ImmutableMap<string, any>) => {
|
||||
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 */
|
||||
|
|
|
@ -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<string, any>) => {
|
||||
return SoapboxConfigRecord(
|
||||
ImmutableMap(fromJS(soapboxConfig)).withMutations(soapboxConfig => {
|
||||
|
@ -186,6 +199,7 @@ export const normalizeSoapboxConfig = (soapboxConfig: Record<string, any>) => {
|
|||
maybeAddMissingColors(soapboxConfig);
|
||||
normalizeCryptoAddresses(soapboxConfig);
|
||||
normalizeAds(soapboxConfig);
|
||||
normalizeAdsAlgorithm(soapboxConfig);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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<string>).concat(fromJS(data));
|
||||
return (items as ImmutableOrderedSet<string>).concat((fromJS(data) as Record<string, any>).map(normalizeTag));
|
||||
} else {
|
||||
return (items as ImmutableOrderedSet<string>).concat(toIds(data));
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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']);
|
||||
});
|
|
@ -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<string, any>, 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;
|
||||
|
|
|
@ -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<string> || []);
|
||||
return filterBadges(tags);
|
||||
};
|
||||
|
||||
export {
|
||||
tagToBadge,
|
||||
badgeToTag,
|
||||
filterBadges,
|
||||
getTagDiff,
|
||||
getBadges,
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
10
yarn.lock
10
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"
|
||||
|
|
Loading…
Reference in New Issue