Merge branch 'poll-improvements' into 'develop'

Refactor & Redesign Polls in Statuses

See merge request soapbox-pub/soapbox-fe!1533
environments/review-develop-3zknud/deployments/323
Justin 2022-06-21 11:44:40 +00:00
commit 4b25bdf635
15 zmienionych plików z 509 dodań i 432 usunięć

Wyświetl plik

@ -1,259 +0,0 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion';
import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals';
import { vote, fetchPoll } from 'soapbox/actions/polls';
import Icon from 'soapbox/components/icon';
import { Text, Button, Stack, HStack } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional_motion';
import { useAppSelector } from 'soapbox/hooks';
import RelativeTimestamp from './relative_timestamp';
import type { Poll as PollEntity, PollOption as PollOptionEntity } from 'soapbox/types/entities';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer' },
votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}' },
});
const PollPercentageBar: React.FC<{percent: number, leading: boolean}> = ({ percent, leading }): JSX.Element => {
return (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { stiffness: 180, damping: 12 }) }}>
{({ width }) =>(
<span
className={classNames('absolute inset-0 h-full inline-block rounded bg-gray-300 dark:bg-slate-900', {
'bg-primary-300 dark:bg-primary-400': leading,
})}
style={{ width: `${width}%` }}
/>
)}
</Motion>
);
};
interface IPollOptionText extends IPollOption {
percent: number,
}
const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active, percent, showResults, onToggle }) => {
const intl = useIntl();
const voted = poll.own_votes?.includes(index);
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
const handleOptionChange: React.EventHandler<React.ChangeEvent> = () => {
onToggle(index);
};
const handleOptionKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
onToggle(index);
e.stopPropagation();
e.preventDefault();
}
};
return (
<label
className={classNames('relative', { 'cursor-pointer': !showResults })}
title={showResults ? message : undefined}
>
<input
className='hidden'
name='vote-options'
type={poll.multiple ? 'checkbox' : 'radio'}
value={index}
checked={active}
onChange={handleOptionChange}
/>
<HStack alignItems='center' className='p-1 text-gray-900 dark:text-gray-300'>
{!showResults && (
<span
className={classNames('inline-block w-4 h-4 flex-none mr-2.5 border border-solid border-primary-600 rounded-full', {
'bg-primary-600': active,
'rounded': poll.multiple,
})}
tabIndex={0}
role={poll.multiple ? 'checkbox' : 'radio'}
onKeyPress={handleOptionKeyPress}
aria-checked={active}
aria-label={option.title}
/>
)}
{showResults && (
<HStack space={2} alignItems='center' className='mr-2.5'>
{voted ? (
<Icon src={require('@tabler/icons/icons/check.svg')} title={intl.formatMessage(messages.voted)} />
) : (
<div className='svg-icon' />
)}
<span className='font-bold'>{Math.round(percent)}%</span>
</HStack>
)}
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
</HStack>
</label>
);
};
interface IPollOption {
poll: PollEntity,
option: PollOptionEntity,
index: number,
showResults?: boolean,
active: boolean,
onToggle: (value: number) => void,
}
const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const { poll, option, showResults } = props;
if (!poll) return null;
const percent = poll.votes_count === 0 ? 0 : (option.votes_count / poll.votes_count) * 100;
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
return (
<div className='relative mb-2.5' key={option.title}>
{showResults && (
<PollPercentageBar percent={percent} leading={leading} />
)}
<PollOptionText percent={percent} {...props} />
</div>
);
};
const RefreshButton: React.FC<{poll: PollEntity}> = ({ poll }): JSX.Element => {
const dispatch = useDispatch();
const handleRefresh: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(fetchPoll(poll.id));
e.stopPropagation();
e.preventDefault();
};
return (
<span>
<button className='underline' onClick={handleRefresh}>
<Text><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></Text>
</button>
</span>
);
};
const VoteButton: React.FC<{poll: PollEntity, selected: Selected}> = ({ poll, selected }): JSX.Element => {
const dispatch = useDispatch();
const handleVote = () => dispatch(vote(poll.id, Object.keys(selected)));
return (
<Button onClick={handleVote} theme='ghost'>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
);
};
interface IPollFooter {
poll: PollEntity,
showResults: boolean,
selected: Selected,
}
const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX.Element => {
const intl = useIntl();
const timeRemaining = poll.expired ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.expires_at} futureDate />;
return (
<Stack space={2}>
{!showResults && <div><VoteButton poll={poll} selected={selected} /></div>}
<Text>
{showResults && (
<><RefreshButton poll={poll} /> · </>
)}
<FormattedMessage id='poll.total_votes' defaultMessage='{count, plural, one {# vote} other {# votes}}' values={{ count: poll.votes_count }} />
{poll.expires_at && <span> · {timeRemaining}</span>}
</Text>
</Stack>
);
};
type Selected = Record<number, boolean>;
interface IPoll {
id: string,
status?: string,
}
const Poll: React.FC<IPoll> = ({ id, status }): JSX.Element | null => {
const dispatch = useDispatch();
const me = useAppSelector((state) => state.me);
const poll = useAppSelector((state) => state.polls.get(id));
const [selected, setSelected] = useState({} as Selected);
const openUnauthorizedModal = () => {
dispatch(openModal('UNAUTHORIZED', {
action: 'POLL_VOTE',
ap_id: status,
}));
};
const toggleOption = (value: number) => {
if (me) {
if (poll?.multiple) {
const tmp = { ...selected };
if (tmp[value]) {
delete tmp[value];
} else {
tmp[value] = true;
}
setSelected(tmp);
} else {
const tmp: Selected = {};
tmp[value] = true;
setSelected(tmp);
}
} else {
openUnauthorizedModal();
}
};
if (!poll) return null;
const showResults = poll.voted || poll.expired;
return (
<div onClick={e => e.stopPropagation()}>
<Stack className={classNames('my-2', { voted: poll.voted })}>
<Stack>
{poll.options.map((option, i) => (
<PollOption
key={i}
poll={poll}
option={option}
index={i}
showResults={showResults}
active={!!selected[i]}
onToggle={toggleOption}
/>
))}
</Stack>
<PollFooter
poll={poll}
showResults={showResults}
selected={selected}
/>
</Stack>
</div>
);
};
export default Poll;

Wyświetl plik

@ -0,0 +1,131 @@
import userEvent from '@testing-library/user-event';
import React from 'react';
import { IntlProvider } from 'react-intl';
import { Provider } from 'react-redux';
import { __stub } from 'soapbox/api';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { mockStore, render, rootReducer, screen } from '../../../jest/test-helpers';
import PollFooter from '../poll-footer';
let poll = normalizePoll({
id: 1,
options: [{ title: 'Apples', votes_count: 0 }],
emojis: [],
expired: false,
expires_at: '2020-03-24T19:33:06.000Z',
multiple: true,
voters_count: 0,
votes_count: 0,
own_votes: null,
voted: false,
});
describe('<PollFooter />', () => {
describe('with "showResults" enabled', () => {
it('renders the Refresh button', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.getByTestId('poll-footer')).toHaveTextContent('Refresh');
});
it('responds to the Refresh button', async() => {
__stub((mock) => {
mock.onGet('/api/v1/polls/1').reply(200, {});
});
const user = userEvent.setup();
const store = mockStore(rootReducer(undefined, {}));
render(
<Provider store={store}>
<IntlProvider locale='en'>
<PollFooter poll={poll} showResults selected={{}} />
</IntlProvider>
</Provider>,
);
await user.click(screen.getByTestId('poll-refresh'));
const actions = store.getActions();
expect(actions).toEqual([
{ type: 'POLL_FETCH_REQUEST' },
{ type: 'POLLS_IMPORT', polls: [{}] },
{ type: 'POLL_FETCH_SUCCESS', poll: {} },
]);
});
it('does not render the Vote button', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.queryAllByTestId('button')).toHaveLength(0);
});
describe('when the Poll has not expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
expired: false,
});
});
it('renders time remaining', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.getByTestId('poll-expiration')).toHaveTextContent('Moments remaining');
});
});
describe('when the Poll has expired', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
expired: true,
});
});
it('renders closed', () => {
render(<PollFooter poll={poll} showResults selected={{}} />);
expect(screen.getByTestId('poll-expiration')).toHaveTextContent('Closed');
});
});
});
describe('with "showResults" disabled', () => {
it('does not render the Refresh button', () => {
render(<PollFooter poll={poll} showResults={false} selected={{}} />);
expect(screen.getByTestId('poll-footer')).not.toHaveTextContent('Refresh');
});
describe('when the Poll is multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
multiple: true,
});
});
it('renders the Vote button', () => {
render(<PollFooter poll={poll} showResults={false} selected={{}} />);
expect(screen.getByTestId('button')).toHaveTextContent('Vote');
});
});
describe('when the Poll is not multiple', () => {
beforeEach(() => {
poll = normalizePoll({
...poll.toJS(),
multiple: false,
});
});
it('does not render the Vote button', () => {
render(<PollFooter poll={poll} showResults={false} selected={{}} />);
expect(screen.queryAllByTestId('button')).toHaveLength(0);
});
});
});
});

Wyświetl plik

@ -0,0 +1,79 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { fetchPoll, vote } from 'soapbox/actions/polls';
import { useAppDispatch } from 'soapbox/hooks';
import RelativeTimestamp from '../relative_timestamp';
import { Button, HStack, Stack, Text } from '../ui';
import type { Selected } from './poll';
import type { Poll as PollEntity } from 'soapbox/types/entities';
const messages = defineMessages({
closed: { id: 'poll.closed', defaultMessage: 'Closed' },
});
interface IPollFooter {
poll: PollEntity,
showResults: boolean,
selected: Selected,
}
const PollFooter: React.FC<IPollFooter> = ({ poll, showResults, selected }): JSX.Element => {
const dispatch = useAppDispatch();
const intl = useIntl();
const handleVote = () => dispatch(vote(poll.id, Object.keys(selected)));
const handleRefresh: React.EventHandler<React.MouseEvent> = (e) => {
dispatch(fetchPoll(poll.id));
e.stopPropagation();
e.preventDefault();
};
const timeRemaining = poll.expired ?
intl.formatMessage(messages.closed) :
<RelativeTimestamp weight='medium' timestamp={poll.expires_at} futureDate />;
return (
<Stack space={4} data-testid='poll-footer'>
{(!showResults && poll?.multiple) && (
<Button onClick={handleVote} theme='primary' block>
<FormattedMessage id='poll.vote' defaultMessage='Vote' />
</Button>
)}
<HStack space={1.5} alignItems='center'>
{showResults && (
<>
<button className='text-gray-600 underline' onClick={handleRefresh} data-testid='poll-refresh'>
<Text theme='muted' weight='medium'>
<FormattedMessage id='poll.refresh' defaultMessage='Refresh' />
</Text>
</button>
<Text theme='muted'>&middot;</Text>
</>
)}
<Text theme='muted' weight='medium'>
<FormattedMessage
id='poll.total_votes'
defaultMessage='{count, plural, one {# vote} other {# votes}}'
values={{ count: poll.votes_count }}
/>
</Text>
{poll.expires_at && (
<>
<Text theme='muted'>&middot;</Text>
<Text weight='medium' theme='muted' data-testid='poll-expiration'>{timeRemaining}</Text>
</>
)}
</HStack>
</Stack>
);
};
export default PollFooter;

Wyświetl plik

@ -0,0 +1,162 @@
import classNames from 'classnames';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Motion, presets, spring } from 'react-motion';
import { HStack, Icon, Text } from '../ui';
import type {
Poll as PollEntity,
PollOption as PollOptionEntity,
} from 'soapbox/types/entities';
const messages = defineMessages({
voted: { id: 'poll.voted', defaultMessage: 'You voted for this answer' },
votes: { id: 'poll.votes', defaultMessage: '{votes, plural, one {# vote} other {# votes}}' },
});
const PollPercentageBar: React.FC<{ percent: number, leading: boolean }> = ({ percent, leading }): JSX.Element => {
return (
<Motion defaultStyle={{ width: 0 }} style={{ width: spring(percent, { ...presets.gentle, precision: 0.1 }) }}>
{({ width }) => (
<span
className='absolute inset-0 h-full inline-block bg-primary-100 dark:bg-primary-500 rounded-l-md'
style={{ width: `${width}%` }}
/>
)}
</Motion>
);
};
interface IPollOptionText extends IPollOption {
percent: number,
}
const PollOptionText: React.FC<IPollOptionText> = ({ poll, option, index, active, onToggle }) => {
const handleOptionChange: React.EventHandler<React.ChangeEvent> = () => onToggle(index);
const handleOptionKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
onToggle(index);
e.stopPropagation();
e.preventDefault();
}
};
return (
<label
className={
classNames('flex relative p-2 bg-white dark:bg-primary-900 cursor-pointer rounded-full border border-solid hover:bg-primary-50 dark:hover:bg-primary-800/50', {
'border-primary-600 ring-1 ring-primary-600 bg-primary-50 dark:bg-primary-800/50 dark:border-primary-300 dark:ring-primary-300': active,
'border-primary-300 dark:border-primary-500': !active,
})
}
>
<input
className='hidden'
name='vote-options'
type={poll.multiple ? 'checkbox' : 'radio'}
value={index}
checked={active}
onChange={handleOptionChange}
/>
<div className='grid items-center w-full'>
<div className='col-start-1 row-start-1 justify-self-center ml-4 mr-6'>
<div className='text-primary-600 dark:text-white'>
<Text
theme='inherit'
weight='medium'
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
/>
</div>
</div>
<div className='col-start-1 row-start-1 justify-self-end flex items-center'>
<span
className={classNames('flex items-center justify-center w-6 h-6 flex-none border border-solid rounded-full', {
'bg-primary-600 border-primary-600 dark:bg-primary-300 dark:border-primary-300': active,
'border-primary-300 bg-white dark:bg-primary-900 dark:border-primary-500': !active,
})}
tabIndex={0}
role={poll.multiple ? 'checkbox' : 'radio'}
onKeyPress={handleOptionKeyPress}
aria-checked={active}
aria-label={option.title}
>
{active && (
<Icon src={require('@tabler/icons/icons/check.svg')} className='text-white dark:text-primary-900 w-4 h-4' />
)}
</span>
</div>
</div>
</label>
);
};
interface IPollOption {
poll: PollEntity,
option: PollOptionEntity,
index: number,
showResults?: boolean,
active: boolean,
onToggle: (value: number) => void,
}
const PollOption: React.FC<IPollOption> = (props): JSX.Element | null => {
const { index, poll, option, showResults } = props;
const intl = useIntl();
if (!poll) return null;
const percent = poll.votes_count === 0 ? 0 : (option.votes_count / poll.votes_count) * 100;
const leading = poll.options.filterNot(other => other.title === option.title).every(other => option.votes_count >= other.votes_count);
const voted = poll.own_votes?.includes(index);
const message = intl.formatMessage(messages.votes, { votes: option.votes_count });
return (
<div key={option.title}>
{showResults ? (
<div title={voted ? message : undefined}>
<HStack
justifyContent='between'
alignItems='center'
className='relative p-2 w-full bg-white dark:bg-primary-800 rounded-md overflow-hidden'
>
<PollPercentageBar percent={percent} leading={leading} />
<div className='text-primary-600 dark:text-white'>
<Text
theme='inherit'
weight='medium'
dangerouslySetInnerHTML={{ __html: option.title_emojified }}
className='relative'
/>
</div>
<HStack space={2} alignItems='center' className='relative'>
{voted ? (
<Icon
src={require('@tabler/icons/icons/circle-check.svg')}
alt={intl.formatMessage(messages.voted)}
className='text-primary-600 dark:text-primary-800 dark:fill-white w-4 h-4'
/>
) : (
<div className='svg-icon' />
)}
<div className='text-primary-600 dark:text-white'>
<Text weight='medium' theme='inherit'>{Math.round(percent)}%</Text>
</div>
</HStack>
</HStack>
</div>
) : (
<PollOptionText percent={percent} {...props} />
)}
</div>
);
};
export default PollOption;

Wyświetl plik

@ -0,0 +1,101 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { openModal } from 'soapbox/actions/modals';
import { vote } from 'soapbox/actions/polls';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import { Stack, Text } from '../ui';
import PollFooter from './poll-footer';
import PollOption from './poll-option';
export type Selected = Record<number, boolean>;
interface IPoll {
id: string,
status?: string,
}
const messages = defineMessages({
multiple: { id: 'poll.chooseMultiple', defaultMessage: 'Choose as many as you\'d like.' },
});
const Poll: React.FC<IPoll> = ({ id, status }): JSX.Element | null => {
const dispatch = useAppDispatch();
const intl = useIntl();
const isLoggedIn = useAppSelector((state) => state.me);
const poll = useAppSelector((state) => state.polls.get(id));
const [selected, setSelected] = useState({} as Selected);
const openUnauthorizedModal = () =>
dispatch(openModal('UNAUTHORIZED', {
action: 'POLL_VOTE',
ap_id: status,
}));
const handleVote = (selectedId: number) => dispatch(vote(id, [selectedId]));
const toggleOption = (value: number) => {
if (isLoggedIn) {
if (poll?.multiple) {
const tmp = { ...selected };
if (tmp[value]) {
delete tmp[value];
} else {
tmp[value] = true;
}
setSelected(tmp);
} else {
const tmp: Selected = {};
tmp[value] = true;
setSelected(tmp);
handleVote(value);
}
} else {
openUnauthorizedModal();
}
};
if (!poll) return null;
const showResults = poll.voted || poll.expired;
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div onClick={e => e.stopPropagation()}>
{poll.multiple && (
<Text theme='muted' size='sm'>
{intl.formatMessage(messages.multiple)}
</Text>
)}
<Stack space={4} className={classNames('mt-4')}>
<Stack space={2}>
{poll.options.map((option, i) => (
<PollOption
key={i}
poll={poll}
option={option}
index={i}
showResults={showResults}
active={!!selected[i]}
onToggle={toggleOption}
/>
))}
</Stack>
<PollFooter
poll={poll}
showResults={showResults}
selected={selected}
/>
</Stack>
</div>
);
};
export default Poll;

Wyświetl plik

@ -179,7 +179,7 @@ class RelativeTimestamp extends React.Component {
const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
return (
<Text {...textProps} color='inherit' tag='time' title={intl.formatDate(date, dateFormatOptions)}>
<Text {...textProps} theme='inherit' tag='time' title={intl.formatDate(date, dateFormatOptions)}>
{relativeTime}
</Text>
);

Wyświetl plik

@ -4,13 +4,14 @@ import { FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import Poll from 'soapbox/components/poll';
import { useSoapboxConfig } from 'soapbox/hooks';
import { addGreentext } from 'soapbox/utils/greentext';
import { onlyEmoji as isOnlyEmoji } from 'soapbox/utils/rich_content';
import { isRtl } from '../rtl';
import Poll from './polls/poll';
import type { Status, Mention } from 'soapbox/types/entities';
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
@ -174,8 +175,8 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
const target = e.target as HTMLElement;
const parentNode = target.parentNode as HTMLElement;
const [ startX, startY ] = startXY.current;
const [ deltaX, deltaY ] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
const [startX, startY] = startXY.current;
const [deltaX, deltaY] = [Math.abs(e.clientX - startX), Math.abs(e.clientY - startY)];
if (target.localName === 'button' || target.localName === 'a' || (parentNode && (parentNode.localName === 'button' || parentNode.localName === 'a'))) {
return;
@ -273,11 +274,12 @@ const StatusContent: React.FC<IStatusContent> = ({ status, expanded = false, onE
output.push(<ReadMoreButton onClick={onClick} key='read-more' />);
}
if (status.poll && typeof status.poll === 'string') {
const hasPoll = status.poll && typeof status.poll === 'string';
if (hasPoll) {
output.push(<Poll id={status.poll} key='poll' status={status.url} />);
}
return <>{output}</>;
return <div className={classNames({ 'bg-gray-100 dark:bg-primary-900 rounded-md p-4': hasPoll })}>{output}</div>;
} else {
const output = [
<div

Wyświetl plik

@ -15,7 +15,7 @@ const themes = {
default: 'text-gray-900 dark:text-gray-100',
danger: 'text-danger-600',
primary: 'text-primary-600 dark:text-primary-400',
muted: 'text-gray-500 dark:text-gray-400',
muted: 'text-gray-500 dark:text-gray-300',
subtle: 'text-gray-400 dark:text-gray-500',
success: 'text-success-600',
inherit: 'text-inherit',

Wyświetl plik

@ -13,7 +13,7 @@ import { buildStatus } from '../builder';
import ScheduledStatusActionBar from './scheduled_status_action_bar';
import type { Account as AccountEntity, Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
interface IScheduledStatus {
statusId: string,
@ -55,7 +55,7 @@ const ScheduledStatus: React.FC<IScheduledStatus> = ({ statusId, ...other }) =>
/>
)}
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
{status.poll && <PollPreview pollId={status.poll as string} />}
<ScheduledStatusActionBar status={status} {...other} />
</div>

Wyświetl plik

@ -14,7 +14,7 @@ import { buildStatus } from '../util/pending_status_builder';
import PollPreview from './poll_preview';
import type { Account as AccountEntity, Poll as PollEntity, Status as StatusEntity } from 'soapbox/types/entities';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
const shouldHaveCard = (pendingStatus: StatusEntity) => {
return Boolean(pendingStatus.content.match(/https?:\/\/\S*/));
@ -81,7 +81,7 @@ const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, mu
<PendingStatusMedia status={status} />
{status.poll && <PollPreview poll={status.poll as PollEntity} />}
{status.poll && <PollPreview pollId={status.poll as string} />}
{status.quote && <QuotedStatus statusId={status.quote as string} />}
</div>

Wyświetl plik

@ -1,43 +1,36 @@
import classNames from 'classnames';
import noop from 'lodash/noop';
import React from 'react';
import { Poll as PollEntity, PollOption as PollOptionEntity } from 'soapbox/types/entities';
import PollOption from 'soapbox/components/polls/poll-option';
import { Stack } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
import { Poll as PollEntity } from 'soapbox/types/entities';
interface IPollPreview {
poll: PollEntity,
pollId: string,
}
const PollPreview: React.FC<IPollPreview> = ({ poll }) => {
const renderOption = (option: PollOptionEntity, index: number) => {
const showResults = poll.voted || poll.expired;
return (
<li key={index}>
<label className={classNames('poll__text', { selectable: !showResults })}>
<input
name='vote-options'
type={poll.multiple ? 'checkbox' : 'radio'}
disabled
/>
<span className={classNames('poll__input', { checkbox: poll.multiple })} />
<span dangerouslySetInnerHTML={{ __html: option.title_emojified }} />
</label>
</li>
);
};
const PollPreview: React.FC<IPollPreview> = ({ pollId }) => {
const poll = useAppSelector((state) => state.polls.get(pollId) as PollEntity);
if (!poll) {
return null;
}
return (
<div className='poll'>
<ul>
{poll.options.map((option, i) => renderOption(option, i))}
</ul>
</div>
<Stack space={2}>
{poll.options.map((option, i) => (
<PollOption
key={i}
poll={poll}
option={option}
index={i}
showResults={false}
active={false}
onToggle={noop}
/>
))}
</Stack>
);
};

Wyświetl plik

@ -780,7 +780,7 @@
"poll.closed": "Closed",
"poll.refresh": "Refresh",
"poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
"poll.vote": "Vote",
"poll.vote": "Submit Vote",
"poll.voted": "You voted for this answer",
"poll.votes": "{votes, plural, one {# vote} other {# votes}}",
"poll_button.add_poll": "Add a poll",

Wyświetl plik

@ -18,7 +18,6 @@
@import 'boost';
@import 'loading';
@import 'ui';
@import 'polls';
// @import 'introduction';
@import 'emoji_picker';
@import 'about';

Wyświetl plik

@ -209,7 +209,7 @@
}
.status__content {
@apply text-gray-900 dark:text-gray-300 break-words text-ellipsis overflow-hidden relative;
@apply text-gray-900 dark:text-gray-100 break-words text-ellipsis overflow-hidden relative;
&:focus {
@apply outline-none;

Wyświetl plik

@ -1,131 +0,0 @@
.poll {
margin-top: 16px;
font-size: 14px;
li {
margin-bottom: 10px;
position: relative;
}
&__text {
position: relative;
display: flex;
padding: 6px 0;
line-height: 18px;
cursor: default;
overflow: hidden;
width: 100%;
text-overflow: ellipsis;
color: var(--primary-text-color--faint);
input[type=radio],
input[type=checkbox] {
display: none;
}
> span:last-child {
flex: 1;
}
.autossugest-input {
flex: 1 1 auto;
}
input[type=text] {
@apply border border-solid border-primary-600;
display: block;
box-sizing: border-box;
width: 100%;
font-size: 14px;
outline: 0;
font-family: inherit;
border-radius: 4px;
padding: 6px 10px;
&:focus {
@apply border border-solid border-primary-500;
}
}
&.selectable {
cursor: pointer;
}
&.editable {
display: flex;
align-items: center;
overflow: visible;
.autosuggest-input {
width: 100%;
}
}
}
&__input {
@apply border border-solid border-primary-600;
display: inline-block;
position: relative;
box-sizing: border-box;
width: 18px;
height: 18px;
flex: 0 0 auto;
margin-right: 10px;
top: -1px;
border-radius: 50%;
vertical-align: middle;
&.checkbox {
border-radius: 4px;
}
&.active {
border-color: $valid-value-color;
background: $valid-value-color;
}
&:active,
&:focus,
&:hover {
border-width: 4px;
background: none;
}
&::-moz-focus-inner {
outline: 0 !important;
border: 0;
}
&:focus,
&:active {
outline: 0 !important;
}
}
.button {
height: 36px;
padding: 0 16px;
margin-right: 10px;
font-size: 14px;
}
&__cancel {
@apply h-5;
.svg-icon {
@apply h-5 w-5;
}
}
}
.muted .poll {
color: var(--primary-text-color);
&__chart {
background: hsla(var(--brand-color_hsl), 0.2);
&.leading {
background: hsla(var(--brand-color_hsl), 0.2);
}
}
}