TypeScript, FC (reducers, search)
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
bdb958a613
commit
6c45dcb109
40 changed files with 490 additions and 57 deletions
|
@ -26,6 +26,8 @@ export interface MenuItem {
|
||||||
icon: string,
|
icon: string,
|
||||||
count?: number,
|
count?: number,
|
||||||
destructive?: boolean,
|
destructive?: boolean,
|
||||||
|
meta?: string,
|
||||||
|
active?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Menu = Array<MenuItem | null>;
|
export type Menu = Array<MenuItem | null>;
|
||||||
|
|
|
@ -10,24 +10,26 @@ import { shortNumberFormat } from '../utils/numbers';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import { HStack, Stack, Text } from './ui';
|
import { HStack, Stack, Text } from './ui';
|
||||||
|
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Hashtag as HashtagEntity } from 'soapbox/reducers/search';
|
||||||
|
import type { TrendingHashtag } from 'soapbox/reducers/trends';
|
||||||
|
|
||||||
interface IHashtag {
|
interface IHashtag {
|
||||||
hashtag: ImmutableMap<string, any>,
|
hashtag: HashtagEntity | TrendingHashtag,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||||
const count = Number(hashtag.getIn(['history', 0, 'accounts']));
|
const history = (hashtag as TrendingHashtag).history;
|
||||||
const brandColor = useSelector((state) => getSoapboxConfig(state).get('brandColor'));
|
const count = Number(history?.get(0)?.accounts);
|
||||||
|
const brandColor = useSelector((state) => getSoapboxConfig(state).brandColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
|
<HStack alignItems='center' justifyContent='between' data-testid='hashtag'>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Permalink href={hashtag.get('url')} to={`/tags/${hashtag.get('name')}`} className='hover:underline'>
|
<Permalink href={hashtag.url} to={`/tags/${hashtag.name}`} className='hover:underline'>
|
||||||
<Text tag='span' size='sm' weight='semibold'>#{hashtag.get('name')}</Text>
|
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
{hashtag.get('history') && (
|
{history && (
|
||||||
<Text theme='muted' size='sm'>
|
<Text theme='muted' size='sm'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='trends.count_by_accounts'
|
id='trends.count_by_accounts'
|
||||||
|
@ -41,12 +43,12 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{hashtag.get('history') && (
|
{history && (
|
||||||
<div className='w-[40px]' data-testid='sparklines'>
|
<div className='w-[40px]' data-testid='sparklines'>
|
||||||
<Sparklines
|
<Sparklines
|
||||||
width={40}
|
width={40}
|
||||||
height={28}
|
height={28}
|
||||||
data={hashtag.get('history').reverse().map((day: ImmutableMap<string, any>) => day.get('uses')).toArray()}
|
data={history.reverse().map((day) => +day.uses).toArray()}
|
||||||
>
|
>
|
||||||
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
|
<SparklinesCurve style={{ fill: 'none' }} color={brandColor} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||||
import PullToRefresh from './pull-to-refresh';
|
import PullToRefresh from './pull-to-refresh';
|
||||||
|
|
||||||
interface IPullable {
|
interface IPullable {
|
||||||
children: JSX.Element,
|
children: React.ReactNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
import { Card, CardBody, CardHeader, CardTitle } from '../card/card';
|
||||||
|
|
||||||
interface IColumn {
|
export interface IColumn {
|
||||||
/** Route the back button goes to. */
|
/** Route the back button goes to. */
|
||||||
backHref?: string,
|
backHref?: string,
|
||||||
/** Column title text. */
|
/** Column title text. */
|
||||||
|
|
|
@ -10,7 +10,7 @@ import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown_m
|
||||||
import type { RootState } from 'soapbox/store';
|
import type { RootState } from 'soapbox/store';
|
||||||
|
|
||||||
const mapStateToProps = (state: RootState) => ({
|
const mapStateToProps = (state: RootState) => ({
|
||||||
isModalOpen: Boolean(state.modals.size && state.modals.last().modalType === 'ACTIONS'),
|
isModalOpen: Boolean(state.modals.size && state.modals.last()!.modalType === 'ACTIONS'),
|
||||||
dropdownPlacement: state.dropdown_menu.placement,
|
dropdownPlacement: state.dropdown_menu.placement,
|
||||||
openDropdownId: state.dropdown_menu.openId,
|
openDropdownId: state.dropdown_menu.openId,
|
||||||
openedViaKeyboard: state.dropdown_menu.keyboard,
|
openedViaKeyboard: state.dropdown_menu.keyboard,
|
||||||
|
|
|
@ -50,8 +50,8 @@ const Search = (props: ISearch) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const value = useAppSelector((state) => state.search.get('value'));
|
const value = useAppSelector((state) => state.search.value);
|
||||||
const submitted = useAppSelector((state) => state.search.get('submitted'));
|
const submitted = useAppSelector((state) => state.search.submitted);
|
||||||
|
|
||||||
const debouncedSubmit = debounce(() => {
|
const debouncedSubmit = debounce(() => {
|
||||||
dispatch(submitSearch());
|
dispatch(submitSearch());
|
||||||
|
|
Binary file not shown.
172
app/soapbox/features/compose/components/search_results.tsx
Normal file
172
app/soapbox/features/compose/components/search_results.tsx
Normal file
|
@ -0,0 +1,172 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { expandSearch, setFilter } from 'soapbox/actions/search';
|
||||||
|
import { fetchTrendingStatuses } from 'soapbox/actions/trending_statuses';
|
||||||
|
import Hashtag from 'soapbox/components/hashtag';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { Tabs } from 'soapbox/components/ui';
|
||||||
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
|
import StatusContainer from 'soapbox/containers/status_container';
|
||||||
|
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
|
||||||
|
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
|
||||||
|
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||||
|
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||||
|
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const SearchResults = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const value = useAppSelector((state) => state.search.submittedValue);
|
||||||
|
const results = useAppSelector((state) => state.search.results);
|
||||||
|
const suggestions = useAppSelector((state) => state.suggestions.items);
|
||||||
|
const trendingStatuses = useAppSelector((state) => state.trending_statuses.items);
|
||||||
|
const trends = useAppSelector((state) => state.trends.items);
|
||||||
|
const submitted = useAppSelector((state) => state.search.submitted);
|
||||||
|
const selectedFilter = useAppSelector((state) => state.search.filter);
|
||||||
|
|
||||||
|
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
|
||||||
|
|
||||||
|
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
|
||||||
|
|
||||||
|
const renderFilterBar = () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.accounts),
|
||||||
|
action: () => selectFilter('accounts'),
|
||||||
|
name: 'accounts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.statuses),
|
||||||
|
action: () => selectFilter('statuses'),
|
||||||
|
name: 'statuses',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage(messages.hashtags),
|
||||||
|
action: () => selectFilter('hashtags'),
|
||||||
|
name: 'hashtags',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(fetchTrendingStatuses());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let searchResults;
|
||||||
|
let hasMore = false;
|
||||||
|
let loaded;
|
||||||
|
let noResultsMessage;
|
||||||
|
let placeholderComponent = PlaceholderStatus as React.ComponentType;
|
||||||
|
|
||||||
|
if (selectedFilter === 'accounts') {
|
||||||
|
hasMore = results.accountsHasMore;
|
||||||
|
loaded = results.accountsLoaded;
|
||||||
|
placeholderComponent = PlaceholderAccount;
|
||||||
|
|
||||||
|
if (results.accounts && results.accounts.size > 0) {
|
||||||
|
searchResults = results.accounts.map(accountId => <AccountContainer key={accountId} id={accountId} />);
|
||||||
|
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
|
||||||
|
searchResults = suggestions.map(suggestion => <AccountContainer key={suggestion.account} id={suggestion.account} />);
|
||||||
|
} else if (loaded) {
|
||||||
|
noResultsMessage = (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.search.accounts'
|
||||||
|
defaultMessage='There are no people results for "{term}"'
|
||||||
|
values={{ term: value }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFilter === 'statuses') {
|
||||||
|
hasMore = results.statusesHasMore;
|
||||||
|
loaded = results.statusesLoaded;
|
||||||
|
|
||||||
|
if (results.statuses && results.statuses.size > 0) {
|
||||||
|
searchResults = results.statuses.map((statusId: string) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<StatusContainer key={statusId} id={statusId} />
|
||||||
|
));
|
||||||
|
} else if (!submitted && trendingStatuses && !trendingStatuses.isEmpty()) {
|
||||||
|
searchResults = trendingStatuses.map((statusId: string) => (
|
||||||
|
// @ts-ignore
|
||||||
|
<StatusContainer key={statusId} id={statusId} />
|
||||||
|
));
|
||||||
|
} else if (loaded) {
|
||||||
|
noResultsMessage = (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.search.statuses'
|
||||||
|
defaultMessage='There are no posts results for "{term}"'
|
||||||
|
values={{ term: value }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFilter === 'hashtags') {
|
||||||
|
hasMore = results.hashtagsHasMore;
|
||||||
|
loaded = results.hashtagsLoaded;
|
||||||
|
placeholderComponent = PlaceholderHashtag;
|
||||||
|
|
||||||
|
if (results.hashtags && results.hashtags.size > 0) {
|
||||||
|
searchResults = results.hashtags.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
|
||||||
|
} else if (!submitted && suggestions && !suggestions.isEmpty()) {
|
||||||
|
searchResults = trends.map(hashtag => <Hashtag key={hashtag.name} hashtag={hashtag} />);
|
||||||
|
} else if (loaded) {
|
||||||
|
noResultsMessage = (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.search.hashtags'
|
||||||
|
defaultMessage='There are no hashtags results for "{term}"'
|
||||||
|
values={{ term: value }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderFilterBar()}
|
||||||
|
|
||||||
|
{noResultsMessage || (
|
||||||
|
<ScrollableList
|
||||||
|
key={selectedFilter}
|
||||||
|
scrollKey={`${selectedFilter}:${value}`}
|
||||||
|
isLoading={submitted && !loaded}
|
||||||
|
showLoading={submitted && !loaded && searchResults?.isEmpty()}
|
||||||
|
hasMore={hasMore}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
placeholderComponent={placeholderComponent}
|
||||||
|
placeholderCount={20}
|
||||||
|
className={classNames({
|
||||||
|
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
|
||||||
|
})}
|
||||||
|
itemClassName={classNames({ 'pb-4': selectedFilter === 'accounts' })}
|
||||||
|
>
|
||||||
|
{searchResults || []}
|
||||||
|
</ScrollableList>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchResults;
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -3,7 +3,7 @@ import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { Column } from 'soapbox/components/ui';
|
import { Column } from 'soapbox/components/ui';
|
||||||
import Search from 'soapbox/features/compose/components/search';
|
import Search from 'soapbox/features/compose/components/search';
|
||||||
import SearchResultsContainer from 'soapbox/features/compose/containers/search_results_container';
|
import SearchResults from 'soapbox/features/compose/components/search_results';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.search', defaultMessage: 'Search' },
|
heading: { id: 'column.search', defaultMessage: 'Search' },
|
||||||
|
@ -16,7 +16,7 @@ const SearchPage = () => {
|
||||||
<Column label={intl.formatMessage(messages.heading)}>
|
<Column label={intl.formatMessage(messages.heading)}>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<Search autoFocus autoSubmit />
|
<Search autoFocus autoSubmit />
|
||||||
<SearchResultsContainer />
|
<SearchResults />
|
||||||
</div>
|
</div>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { render, screen } from '../../../../jest/test-helpers';
|
import { render, screen } from '../../../../jest/test-helpers';
|
||||||
|
@ -7,16 +7,16 @@ import TrendsPanel from '../trends-panel';
|
||||||
describe('<TrendsPanel />', () => {
|
describe('<TrendsPanel />', () => {
|
||||||
it('renders trending hashtags', () => {
|
it('renders trending hashtags', () => {
|
||||||
const store = {
|
const store = {
|
||||||
trends: ImmutableMap({
|
trends: {
|
||||||
items: fromJS([{
|
items: ImmutableList([{
|
||||||
name: 'hashtag 1',
|
name: 'hashtag 1',
|
||||||
history: [{
|
history: ImmutableList([{
|
||||||
day: '1652745600',
|
day: '1652745600',
|
||||||
uses: '294',
|
uses: '294',
|
||||||
accounts: '180',
|
accounts: '180',
|
||||||
}],
|
}]),
|
||||||
}]),
|
}]),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TrendsPanel limit={1} />, null, store);
|
render(<TrendsPanel limit={1} />, null, store);
|
||||||
|
@ -27,18 +27,18 @@ describe('<TrendsPanel />', () => {
|
||||||
|
|
||||||
it('renders multiple trends', () => {
|
it('renders multiple trends', () => {
|
||||||
const store = {
|
const store = {
|
||||||
trends: ImmutableMap({
|
trends: {
|
||||||
items: fromJS([
|
items: ImmutableList([
|
||||||
{
|
{
|
||||||
name: 'hashtag 1',
|
name: 'hashtag 1',
|
||||||
history: [{ accounts: [] }],
|
history: ImmutableList([{ accounts: [] }]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'hashtag 2',
|
name: 'hashtag 2',
|
||||||
history: [{ accounts: [] }],
|
history: ImmutableList([{ accounts: [] }]),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TrendsPanel limit={3} />, null, store);
|
render(<TrendsPanel limit={3} />, null, store);
|
||||||
|
@ -47,18 +47,18 @@ describe('<TrendsPanel />', () => {
|
||||||
|
|
||||||
it('respects the limit prop', () => {
|
it('respects the limit prop', () => {
|
||||||
const store = {
|
const store = {
|
||||||
trends: ImmutableMap({
|
trends: {
|
||||||
items: fromJS([
|
items: ImmutableList([
|
||||||
{
|
{
|
||||||
name: 'hashtag 1',
|
name: 'hashtag 1',
|
||||||
history: [{ accounts: [] }],
|
history: ImmutableList([{ accounts: [] }]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'hashtag 2',
|
name: 'hashtag 2',
|
||||||
history: [{ accounts: [] }],
|
history: ImmutableList([{ accounts: [] }]),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TrendsPanel limit={1} />, null, store);
|
render(<TrendsPanel limit={1} />, null, store);
|
||||||
|
@ -67,9 +67,9 @@ describe('<TrendsPanel />', () => {
|
||||||
|
|
||||||
it('renders empty', () => {
|
it('renders empty', () => {
|
||||||
const store = {
|
const store = {
|
||||||
trends: ImmutableMap({
|
trends: {
|
||||||
items: fromJS([]),
|
items: ImmutableList([]),
|
||||||
}),
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
render(<TrendsPanel limit={1} />, null, store);
|
render(<TrendsPanel limit={1} />, null, store);
|
||||||
|
|
Binary file not shown.
68
app/soapbox/features/ui/components/account_note_modal.tsx
Normal file
68
app/soapbox/features/ui/components/account_note_modal.tsx
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { changeAccountNoteComment, submitAccountNote } from 'soapbox/actions/account-notes';
|
||||||
|
import { closeModal } from 'soapbox/actions/modals';
|
||||||
|
import { Modal, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
|
||||||
|
save: { id: 'account_note.save', defaultMessage: 'Save' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
const AccountNoteModal = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const isSubmitting = useAppSelector((state) => state.account_notes.edit.isSubmitting);
|
||||||
|
const account = useAppSelector((state) => getAccount(state, state.account_notes.edit.account!));
|
||||||
|
const comment = useAppSelector((state) => state.account_notes.edit.comment);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
dispatch(closeModal('ACCOUNT_NOTE'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCommentChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
|
||||||
|
dispatch(changeAccountNoteComment(e.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
dispatch(submitAccountNote());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = e => {
|
||||||
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<FormattedMessage id='account_note.target' defaultMessage='Note for @{target}' values={{ target: account!.acct }} />}
|
||||||
|
onClose={onClose}
|
||||||
|
confirmationAction={handleSubmit}
|
||||||
|
confirmationText={intl.formatMessage(messages.save)}
|
||||||
|
confirmationDisabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Text theme='muted'>
|
||||||
|
<FormattedMessage id='account_note.hint' defaultMessage='You can keep notes about this user for yourself (this will not be shared with them):' />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className='setting-text light'
|
||||||
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
|
value={comment}
|
||||||
|
onChange={handleCommentChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountNoteModal;
|
Binary file not shown.
Binary file not shown.
36
app/soapbox/features/ui/components/column.tsx
Normal file
36
app/soapbox/features/ui/components/column.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Pullable from 'soapbox/components/pullable';
|
||||||
|
import { Column } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import ColumnHeader from './column_header';
|
||||||
|
|
||||||
|
import type { IColumn } from 'soapbox/components/ui/column/column';
|
||||||
|
|
||||||
|
interface IUIColumn extends IColumn {
|
||||||
|
heading?: string,
|
||||||
|
icon?: string,
|
||||||
|
active?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
const UIColumn: React.FC<IUIColumn> = ({
|
||||||
|
heading,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
active,
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const columnHeaderId = heading && heading.replace(/ /g, '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column aria-labelledby={columnHeaderId} {...rest}>
|
||||||
|
{heading && <ColumnHeader icon={icon} active={active} type={heading} columnHeaderId={columnHeaderId} />}
|
||||||
|
<Pullable>
|
||||||
|
{children}
|
||||||
|
</Pullable>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UIColumn;
|
Binary file not shown.
23
app/soapbox/features/ui/components/column_forbidden.tsx
Normal file
23
app/soapbox/features/ui/components/column_forbidden.tsx
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import Column from './column';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'column_forbidden.title', defaultMessage: 'Forbidden' },
|
||||||
|
body: { id: 'column_forbidden.body', defaultMessage: 'You do not have permission to access this page.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ColumnForbidden = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.title)}>
|
||||||
|
<div className='error-column'>
|
||||||
|
{intl.formatMessage(messages.body)}
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColumnForbidden;
|
Binary file not shown.
47
app/soapbox/features/ui/components/column_header.tsx
Normal file
47
app/soapbox/features/ui/components/column_header.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// import classNames from 'classnames';
|
||||||
|
// import Icon from 'soapbox/components/icon';
|
||||||
|
import SubNavigation from 'soapbox/components/sub_navigation';
|
||||||
|
|
||||||
|
interface IColumnHeader {
|
||||||
|
icon?: string,
|
||||||
|
type: string
|
||||||
|
active?: boolean,
|
||||||
|
columnHeaderId?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ColumnHeader: React.FC<IColumnHeader> = ({ type }) => {
|
||||||
|
return <SubNavigation message={type} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ColumnHeader;
|
||||||
|
|
||||||
|
// export default class ColumnHeader extends React.PureComponent {
|
||||||
|
|
||||||
|
// static propTypes = {
|
||||||
|
// icon: PropTypes.string,
|
||||||
|
// type: PropTypes.string,
|
||||||
|
// active: PropTypes.bool,
|
||||||
|
// onClick: PropTypes.func,
|
||||||
|
// columnHeaderId: PropTypes.string,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// handleClick = () => {
|
||||||
|
// this.props.onClick();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// render() {
|
||||||
|
// const { icon, type, active, columnHeaderId } = this.props;
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
|
||||||
|
// <button onClick={this.handleClick}>
|
||||||
|
// {icon && <Icon id={icon} fixedWidth className='column-header__icon' />}
|
||||||
|
// {type}
|
||||||
|
// </button>
|
||||||
|
// </h1>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
|
@ -77,14 +77,14 @@ const ReportModal = ({ onClose }: IReportModal) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const accountId = useAppSelector((state) => state.reports.getIn(['new', 'account_id']) as string);
|
const accountId = useAppSelector((state) => state.reports.new.account_id);
|
||||||
const account = useAccount(accountId);
|
const account = useAccount(accountId as string);
|
||||||
|
|
||||||
const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean);
|
const isBlocked = useAppSelector((state) => state.reports.new.block);
|
||||||
const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean);
|
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
|
||||||
const rules = useAppSelector((state) => state.rules.items);
|
const rules = useAppSelector((state) => state.rules.items);
|
||||||
const ruleIds = useAppSelector((state) => state.reports.getIn(['new', 'rule_ids']) as ImmutableSet<string>);
|
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
|
||||||
const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet<string>);
|
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
|
||||||
|
|
||||||
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
|
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
|
||||||
const shouldRequireRule = rules.length > 0;
|
const shouldRequireRule = rules.length > 0;
|
||||||
|
|
|
@ -30,11 +30,11 @@ const OtherActionsStep = ({ account }: IOtherActionsStep) => {
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.getIn([`account:${account.id}:with_replies`, 'items'])).union(state.reports.getIn(['new', 'status_ids']) as Iterable<unknown>) as OrderedSet<string>);
|
const statusIds = useAppSelector((state) => OrderedSet(state.timelines.getIn([`account:${account.id}:with_replies`, 'items'])).union(state.reports.new.status_ids) as OrderedSet<string>);
|
||||||
const isBlocked = useAppSelector((state) => state.reports.getIn(['new', 'block']) as boolean);
|
const isBlocked = useAppSelector((state) => state.reports.new.block);
|
||||||
const isForward = useAppSelector((state) => state.reports.getIn(['new', 'forward']) as boolean);
|
const isForward = useAppSelector((state) => state.reports.new.forward);
|
||||||
const canForward = isRemote(account as any) && features.federating;
|
const canForward = isRemote(account as any) && features.federating;
|
||||||
const isSubmitting = useAppSelector((state) => state.reports.getIn(['new', 'isSubmitting']) as boolean);
|
const isSubmitting = useAppSelector((state) => state.reports.new.isSubmitting);
|
||||||
|
|
||||||
const [showAdditionalStatuses, setShowAdditionalStatuses] = useState<boolean>(false);
|
const [showAdditionalStatuses, setShowAdditionalStatuses] = useState<boolean>(false);
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ import { fetchRules } from 'soapbox/actions/rules';
|
||||||
import { FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
|
import { FormGroup, Stack, Text, Textarea } from 'soapbox/components/ui';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
import type { Set as ImmutableSet } from 'immutable';
|
|
||||||
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
import type { ReducerAccount } from 'soapbox/reducers/accounts';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -31,12 +30,12 @@ const ReasonStep = (_props: IReasonStep) => {
|
||||||
const [isNearBottom, setNearBottom] = useState<boolean>(false);
|
const [isNearBottom, setNearBottom] = useState<boolean>(false);
|
||||||
const [isNearTop, setNearTop] = useState<boolean>(true);
|
const [isNearTop, setNearTop] = useState<boolean>(true);
|
||||||
|
|
||||||
const comment = useAppSelector((state) => state.reports.getIn(['new', 'comment']) as string);
|
const comment = useAppSelector((state) => state.reports.new.comment);
|
||||||
const rules = useAppSelector((state) => state.rules.items);
|
const rules = useAppSelector((state) => state.rules.items);
|
||||||
const ruleIds = useAppSelector((state) => state.reports.getIn(['new', 'rule_ids']) as ImmutableSet<string>);
|
const ruleIds = useAppSelector((state) => state.reports.new.rule_ids);
|
||||||
const shouldRequireRule = rules.length > 0;
|
const shouldRequireRule = rules.length > 0;
|
||||||
|
|
||||||
const selectedStatusIds = useAppSelector((state) => state.reports.getIn(['new', 'status_ids']) as ImmutableSet<string>);
|
const selectedStatusIds = useAppSelector((state) => state.reports.new.status_ids);
|
||||||
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
|
const isReportingAccount = useMemo(() => selectedStatusIds.size === 0, []);
|
||||||
|
|
||||||
const handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleCommentChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
|
Binary file not shown.
|
@ -1,4 +1,3 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
@ -15,10 +14,10 @@ interface ITrendsPanel {
|
||||||
const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
const trends: any = useAppSelector((state) => state.trends.get('items'));
|
const trends = useAppSelector((state) => state.trends.get('items'));
|
||||||
|
|
||||||
const sortedTrends = React.useMemo(() => {
|
const sortedTrends = React.useMemo(() => {
|
||||||
return trends.sort((a: ImmutableMap<string, any>, b: ImmutableMap<string, any>) => {
|
return trends.sort((a, b) => {
|
||||||
const num_a = Number(a.getIn(['history', 0, 'accounts']));
|
const num_a = Number(a.getIn(['history', 0, 'accounts']));
|
||||||
const num_b = Number(b.getIn(['history', 0, 'accounts']));
|
const num_b = Number(b.getIn(['history', 0, 'accounts']));
|
||||||
return num_b - num_a;
|
return num_b - num_a;
|
||||||
|
@ -35,8 +34,8 @@ const TrendsPanel = ({ limit }: ITrendsPanel) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
|
<Widget title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}>
|
||||||
{sortedTrends.map((hashtag: ImmutableMap<string, any>) => (
|
{sortedTrends.map((hashtag) => (
|
||||||
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
<Hashtag key={hashtag.name} hashtag={hashtag} />
|
||||||
))}
|
))}
|
||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -12,7 +12,7 @@ import {
|
||||||
const EditRecord = ImmutableRecord({
|
const EditRecord = ImmutableRecord({
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
account: null,
|
account: null,
|
||||||
comment: null,
|
comment: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const ReducerRecord = ImmutableRecord({
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
@ -26,7 +26,7 @@ export default function account_notes(state: State = ReducerRecord(), action: An
|
||||||
case ACCOUNT_NOTE_INIT_MODAL:
|
case ACCOUNT_NOTE_INIT_MODAL:
|
||||||
return state.withMutations((state) => {
|
return state.withMutations((state) => {
|
||||||
state.setIn(['edit', 'isSubmitting'], false);
|
state.setIn(['edit', 'isSubmitting'], false);
|
||||||
state.setIn(['edit', 'account_id'], action.account.get('id'));
|
state.setIn(['edit', 'account'], action.account.get('id'));
|
||||||
state.setIn(['edit', 'comment'], action.comment);
|
state.setIn(['edit', 'comment'], action.comment);
|
||||||
});
|
});
|
||||||
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,5 +1,4 @@
|
||||||
import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, Record as ImmutableRecord, fromJS } from 'immutable';
|
||||||
import { AnyAction } from 'redux';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MFA_FETCH_SUCCESS,
|
MFA_FETCH_SUCCESS,
|
||||||
|
@ -11,6 +10,8 @@ import {
|
||||||
REVOKE_TOKEN_SUCCESS,
|
REVOKE_TOKEN_SUCCESS,
|
||||||
} from '../actions/security';
|
} from '../actions/security';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
const TokenRecord = ImmutableRecord({
|
const TokenRecord = ImmutableRecord({
|
||||||
id: 0,
|
id: 0,
|
||||||
app_name: '',
|
app_name: '',
|
Binary file not shown.
37
app/soapbox/reducers/trending_statuses.ts
Normal file
37
app/soapbox/reducers/trending_statuses.ts
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import { OrderedSet as ImmutableOrderedSet, Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TRENDING_STATUSES_FETCH_REQUEST,
|
||||||
|
TRENDING_STATUSES_FETCH_SUCCESS,
|
||||||
|
} from 'soapbox/actions/trending_statuses';
|
||||||
|
import { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
|
||||||
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
items: ImmutableOrderedSet<string>(),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
|
type APIEntities = Array<APIEntity>;
|
||||||
|
|
||||||
|
const toIds = (items: APIEntities) => ImmutableOrderedSet(items.map(item => item.id));
|
||||||
|
|
||||||
|
const importStatuses = (state: State, statuses: APIEntities) => {
|
||||||
|
return state.withMutations(state => {
|
||||||
|
state.set('items', toIds(statuses));
|
||||||
|
state.set('isLoading', false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function trending_statuses(state: State = ReducerRecord(), action: AnyAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case TRENDING_STATUSES_FETCH_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case TRENDING_STATUSES_FETCH_SUCCESS:
|
||||||
|
return importStatuses(state, action.statuses);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
47
app/soapbox/reducers/trends.ts
Normal file
47
app/soapbox/reducers/trends.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { List as ImmutableList, Record as ImmutableRecord } from 'immutable';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TRENDS_FETCH_REQUEST,
|
||||||
|
TRENDS_FETCH_SUCCESS,
|
||||||
|
TRENDS_FETCH_FAIL,
|
||||||
|
} from '../actions/trends';
|
||||||
|
|
||||||
|
import type { AnyAction } from 'redux';
|
||||||
|
import type { APIEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const HistoryRecord = ImmutableRecord({
|
||||||
|
accounts: '',
|
||||||
|
day: '',
|
||||||
|
uses: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const TrendingHashtagRecord = ImmutableRecord({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
history: ImmutableList<History>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ReducerRecord = ImmutableRecord({
|
||||||
|
items: ImmutableList<TrendingHashtag>(),
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
|
type History = ReturnType<typeof HistoryRecord>;
|
||||||
|
export type TrendingHashtag = ReturnType<typeof TrendingHashtagRecord>;
|
||||||
|
|
||||||
|
export default function trendsReducer(state: State = ReducerRecord(), action: AnyAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case TRENDS_FETCH_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case TRENDS_FETCH_SUCCESS:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('items', ImmutableList(action.tags.map((item: APIEntity) => TrendingHashtagRecord({ ...item, history: ImmutableList(item.history.map(HistoryRecord)) }))));
|
||||||
|
map.set('isLoading', false);
|
||||||
|
});
|
||||||
|
case TRENDS_FETCH_FAIL:
|
||||||
|
return state.set('isLoading', false);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue