init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconCloseLarge } from '../../../base/icons/svg';
import Button from '../../../base/ui/components/web/Button';
import Checkbox from '../../../base/ui/components/web/Checkbox';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { editPoll, removePoll } from '../../actions';
import { isSubmitAnswerDisabled } from '../../functions';
import AbstractPollAnswer, { AbstractProps } from '../AbstractPollAnswer';
const useStyles = makeStyles()(theme => {
return {
container: {
margin: '24px',
padding: '16px',
backgroundColor: theme.palette.ui02,
borderRadius: '8px',
wordBreak: 'break-word'
},
closeBtn: {
cursor: 'pointer',
float: 'right'
},
header: {
marginBottom: '24px'
},
question: {
...theme.typography.heading6,
color: theme.palette.text01,
marginBottom: '8px'
},
creator: {
...theme.typography.bodyShortRegular,
color: theme.palette.text02
},
answerList: {
listStyleType: 'none',
margin: 0,
padding: 0,
marginBottom: '24px'
},
answer: {
display: 'flex',
marginBottom: '16px'
},
footer: {
display: 'flex',
justifyContent: 'flex-end'
},
buttonMargin: {
marginRight: theme.spacing(3)
}
};
});
const PollAnswer = ({
creatorName,
checkBoxStates,
poll,
pollId,
setCheckbox,
setCreateMode,
skipAnswer,
skipChangeVote,
sendPoll,
submitAnswer,
t
}: AbstractProps) => {
const { changingVote, saved: pollSaved } = poll;
const dispatch = useDispatch();
const { classes } = useStyles();
return (
<div className = { classes.container }>
{
pollSaved && <Icon
ariaLabel = { t('polls.closeButton') }
className = { classes.closeBtn }
onClick = { () => dispatch(removePoll(pollId, poll)) }
role = 'button'
src = { IconCloseLarge }
tabIndex = { 0 } />
}
<div className = { classes.header }>
<div className = { classes.question }>
{ poll.question }
</div>
<div className = { classes.creator }>
{ t('polls.by', { name: creatorName }) }
</div>
</div>
<ul className = { classes.answerList }>
{
poll.answers.map((answer, index: number) => (
<li
className = { classes.answer }
key = { index }>
<Checkbox
checked = { checkBoxStates[index] }
disabled = { poll.saved }
key = { index }
label = { answer.name }
onChange = { ev => setCheckbox(index, ev.target.checked) } />
</li>
))
}
</ul>
<div className = { classes.footer } >
{
pollSaved ? <>
<Button
accessibilityLabel = { t('polls.answer.edit') }
className = { classes.buttonMargin }
labelKey = { 'polls.answer.edit' }
onClick = { () => {
setCreateMode(true);
dispatch(editPoll(pollId, true));
} }
type = { BUTTON_TYPES.SECONDARY } />
<Button
accessibilityLabel = { t('polls.answer.send') }
labelKey = { 'polls.answer.send' }
onClick = { sendPoll } />
</> : <>
<Button
accessibilityLabel = { t('polls.answer.skip') }
className = { classes.buttonMargin }
labelKey = { 'polls.answer.skip' }
onClick = { changingVote ? skipChangeVote : skipAnswer }
type = { BUTTON_TYPES.SECONDARY } />
<Button
accessibilityLabel = { t('polls.answer.submit') }
disabled = { isSubmitAnswerDisabled(checkBoxStates) }
labelKey = { 'polls.answer.submit' }
onClick = { submitAnswer } />
</>
}
</div>
</div>
);
};
/*
* We apply AbstractPollAnswer to fill in the AbstractProps common
* to both the web and native implementations.
*/
// eslint-disable-next-line new-cap
export default AbstractPollAnswer(PollAnswer);

View File

@@ -0,0 +1,284 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.web';
import { editPoll } from '../../actions';
import { ANSWERS_LIMIT, CHAR_LIMIT } from '../../constants';
import AbstractPollCreate, { AbstractProps } from '../AbstractPollCreate';
const useStyles = makeStyles()(theme => {
return {
container: {
height: '100%',
position: 'relative'
},
createContainer: {
padding: '0 24px',
height: 'calc(100% - 88px)',
overflowY: 'auto'
},
header: {
...theme.typography.heading6,
color: theme.palette.text01,
margin: '24px 0 16px'
},
questionContainer: {
paddingBottom: '24px',
borderBottom: `1px solid ${theme.palette.ui03}`
},
answerList: {
listStyleType: 'none',
margin: 0,
padding: 0
},
answer: {
marginBottom: '24px'
},
removeOption: {
...theme.typography.bodyShortRegular,
color: theme.palette.link01,
marginTop: '8px',
border: 0,
background: 'transparent'
},
addButtonContainer: {
display: 'flex'
},
footer: {
position: 'absolute',
bottom: 0,
display: 'flex',
justifyContent: 'flex-end',
padding: '24px',
width: '100%',
boxSizing: 'border-box'
},
buttonMargin: {
marginRight: theme.spacing(3)
}
};
});
const PollCreate = ({
addAnswer,
answers,
editingPoll,
editingPollId,
isSubmitDisabled,
onSubmit,
question,
removeAnswer,
setAnswer,
setCreateMode,
setQuestion,
t
}: AbstractProps) => {
const { classes } = useStyles();
const dispatch = useDispatch();
/*
* This ref stores the Array of answer input fields, allowing us to focus on them.
* This array is maintained by registerfieldRef and the useEffect below.
*/
const answerInputs = useRef<Array<HTMLInputElement>>([]);
const registerFieldRef = useCallback((i, r) => {
if (r === null) {
return;
}
answerInputs.current[i] = r;
}, [ answerInputs ]);
useEffect(() => {
answerInputs.current = answerInputs.current.slice(0, answers.length);
}, [ answers ]);
/*
* This state allows us to requestFocus asynchronously, without having to worry
* about whether a newly created input field has been rendered yet or not.
*/
const [ lastFocus, requestFocus ] = useState<number | null>(null);
useEffect(() => {
if (lastFocus === null) {
return;
}
const input = answerInputs.current[lastFocus];
if (input === undefined) {
return;
}
input.focus();
}, [ lastFocus ]);
const checkModifiers = useCallback(ev => {
// Composition events used to add accents to characters
// despite their absence from standard US keyboards,
// to build up logograms of many Asian languages
// from their base components or categories and so on.
if (ev.isComposing || ev.keyCode === 229) {
// keyCode 229 means that user pressed some button,
// but input method is still processing that.
// This is a standard behavior for some input methods
// like entering japanese or сhinese hieroglyphs.
return true;
}
// Because this isn't done automatically on MacOS
if (ev.key === 'Enter' && ev.metaKey) {
ev.preventDefault();
onSubmit();
return;
}
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
return;
}
}, []);
const onQuestionKeyDown = useCallback(ev => {
if (checkModifiers(ev)) {
return;
}
if (ev.key === 'Enter') {
requestFocus(0);
ev.preventDefault();
}
}, []);
// Called on keypress in answer fields
const onAnswerKeyDown = useCallback((i, ev) => {
if (checkModifiers(ev)) {
return;
}
if (ev.key === 'Enter') {
// We add a new option input
// only if we are on the last option input
if (i === answers.length - 1) {
addAnswer(i + 1);
}
requestFocus(i + 1);
ev.preventDefault();
} else if (ev.key === 'Backspace' && ev.target.value === '' && answers.length > 1) {
removeAnswer(i);
requestFocus(i > 0 ? i - 1 : 0);
ev.preventDefault();
} else if (ev.key === 'ArrowDown') {
if (i === answers.length - 1) {
addAnswer();
}
requestFocus(i + 1);
ev.preventDefault();
} else if (ev.key === 'ArrowUp') {
if (i === 0) {
addAnswer(0);
requestFocus(0);
} else {
requestFocus(i - 1);
}
ev.preventDefault();
}
}, [ answers, addAnswer, removeAnswer, requestFocus ]);
/* eslint-disable react/jsx-no-bind */
return (<form
className = { classes.container }
onSubmit = { onSubmit }>
<div className = { classes.createContainer }>
<div className = { classes.header }>
{ t('polls.create.create') }
</div>
<div className = { classes.questionContainer }>
<Input
autoFocus = { true }
id = 'polls-create-input'
label = { t('polls.create.pollQuestion') }
maxLength = { CHAR_LIMIT }
onChange = { setQuestion }
onKeyPress = { onQuestionKeyDown }
placeholder = { t('polls.create.questionPlaceholder') }
textarea = { true }
value = { question } />
</div>
<ol className = { classes.answerList }>
{answers.map((answer, i: number) => {
const isIdenticalAnswer = answers.slice(0, i).length === 0 ? false
: answers.slice(0, i).some(prevAnswer =>
prevAnswer.name === answer.name
&& prevAnswer.name !== '' && answer.name !== '');
return (<li
className = { classes.answer }
key = { i }>
<Input
bottomLabel = { (isIdenticalAnswer ? t('polls.errors.notUniqueOption',
{ index: i + 1 }) : '') }
error = { isIdenticalAnswer }
id = { `polls-answer-input-${i}` }
label = { t('polls.create.pollOption', { index: i + 1 }) }
maxLength = { CHAR_LIMIT }
onChange = { name => setAnswer(i, {
name,
voters: []
}) }
onKeyPress = { ev => onAnswerKeyDown(i, ev) }
placeholder = { t('polls.create.answerPlaceholder', { index: i + 1 }) }
ref = { r => registerFieldRef(i, r) }
textarea = { true }
value = { answer.name } />
{ answers.length > 2
&& <button
className = { classes.removeOption }
onClick = { () => removeAnswer(i) }
type = 'button'>
{ t('polls.create.removeOption') }
</button>}
</li>);
}
)}
</ol>
<div className = { classes.addButtonContainer }>
<Button
accessibilityLabel = { t('polls.create.addOption') }
disabled = { answers.length >= ANSWERS_LIMIT }
labelKey = { 'polls.create.addOption' }
onClick = { () => {
addAnswer();
requestFocus(answers.length);
} }
type = { BUTTON_TYPES.SECONDARY } />
</div>
</div>
<div className = { classes.footer }>
<Button
accessibilityLabel = { t('polls.create.cancel') }
className = { classes.buttonMargin }
labelKey = { 'polls.create.cancel' }
onClick = { () => {
setCreateMode(false);
editingPollId
&& editingPoll?.editing
&& dispatch(editPoll(editingPollId, false));
} }
type = { BUTTON_TYPES.SECONDARY } />
<Button
accessibilityLabel = { t('polls.create.save') }
disabled = { isSubmitDisabled }
isSubmit = { true }
labelKey = { 'polls.create.save' } />
</div>
</form>);
};
/*
* We apply AbstractPollCreate to fill in the AbstractProps common
* to both the web and native implementations.
*/
// eslint-disable-next-line new-cap
export default AbstractPollCreate(PollCreate);

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { shouldShowResults } from '../../functions';
import PollAnswer from './PollAnswer';
import PollResults from './PollResults';
interface IProps {
/**
* Id of the poll.
*/
pollId: string;
/**
* Create mode control.
*/
setCreateMode: (mode: boolean) => void;
}
const PollItem = React.forwardRef<HTMLDivElement, IProps>(({ pollId, setCreateMode }: IProps, ref) => {
const showResults = useSelector(shouldShowResults(pollId));
return (
<div ref = { ref }>
{ showResults
? <PollResults
key = { pollId }
pollId = { pollId } />
: <PollAnswer
pollId = { pollId }
setCreateMode = { setCreateMode } />
}
</div>
);
});
export default PollItem;

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import AbstractPollResults, { AbstractProps } from '../AbstractPollResults';
const useStyles = makeStyles()(theme => {
return {
container: {
margin: '24px',
padding: '16px',
backgroundColor: theme.palette.ui02,
borderRadius: '8px',
wordBreak: 'break-word'
},
header: {
marginBottom: '16px'
},
question: {
...theme.typography.heading6,
color: theme.palette.text01,
marginBottom: '8px'
},
creator: {
...theme.typography.bodyShortRegular,
color: theme.palette.text02
},
resultList: {
listStyleType: 'none',
margin: 0,
padding: 0,
'& li': {
marginBottom: '16px'
}
},
answerName: {
display: 'flex',
flexShrink: 1,
overflowWrap: 'anywhere',
...theme.typography.bodyShortRegular,
color: theme.palette.text01,
marginBottom: '4px'
},
answerResultContainer: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
minWidth: '10em'
},
barContainer: {
backgroundColor: theme.palette.ui03,
borderRadius: '4px',
height: '6px',
maxWidth: '160px',
width: '158px',
flexGrow: 1,
marginTop: '2px'
},
bar: {
height: '6px',
borderRadius: '4px',
backgroundColor: theme.palette.action01
},
voteCount: {
flex: 1,
textAlign: 'right',
...theme.typography.bodyShortBold,
color: theme.palette.text01
},
voters: {
margin: 0,
marginTop: '4px',
listStyleType: 'none',
display: 'flex',
flexDirection: 'column',
backgroundColor: theme.palette.ui03,
borderRadius: theme.shape.borderRadius,
padding: '8px 16px',
'& li': {
...theme.typography.bodyShortRegular,
color: theme.palette.text01,
margin: 0,
marginBottom: '2px',
'&:last-of-type': {
marginBottom: 0
}
}
},
buttonsContainer: {
display: 'flex',
justifyContent: 'space-between',
'& button': {
border: 0,
backgroundColor: 'transparent',
...theme.typography.bodyShortRegular,
color: theme.palette.link01
}
}
};
});
/**
* Component that renders the poll results.
*
* @param {Props} props - The passed props.
* @returns {React.Node}
*/
const PollResults = ({
answers,
changeVote,
creatorName,
haveVoted,
showDetails,
question,
t,
toggleIsDetailed
}: AbstractProps) => {
const { classes } = useStyles();
return (
<div className = { classes.container }>
<div className = { classes.header }>
<div className = { classes.question }>
{question}
</div>
<div className = { classes.creator }>
{t('polls.by', { name: creatorName })}
</div>
</div>
<ul className = { classes.resultList }>
{answers.map(({ name, percentage, voters, voterCount }, index) =>
(<li key = { index }>
<div className = { classes.answerName }>
{name}
</div>
<div className = { classes.answerResultContainer }>
<span className = { classes.barContainer }>
<div
className = { classes.bar }
style = {{ width: `${percentage}%` }} />
</span>
<div className = { classes.voteCount }>
{voterCount} ({percentage}%)
</div>
</div>
{showDetails && voters && voterCount > 0
&& <ul className = { classes.voters }>
{voters.map(voter =>
<li key = { voter?.id }>{voter?.name}</li>
)}
</ul>}
</li>)
)}
</ul>
<div className = { classes.buttonsContainer }>
<button
onClick = { toggleIsDetailed }>
{showDetails ? t('polls.results.hideDetailedResults') : t('polls.results.showDetailedResults')}
</button>
<button
onClick = { changeVote }>
{haveVoted ? t('polls.results.changeVote') : t('polls.results.vote')}
</button>
</div>
</div>
);
};
/*
* We apply AbstractPollResults to fill in the AbstractProps common
* to both the web and native implementations.
*/
// eslint-disable-next-line new-cap
export default AbstractPollResults(PollResults);

View File

@@ -0,0 +1,93 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconMessage } from '../../../base/icons/svg';
import { browser } from '../../../base/lib-jitsi-meet';
import PollItem from './PollItem';
const useStyles = makeStyles()(theme => {
return {
container: {
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
},
emptyIcon: {
width: '100px',
padding: '16px',
'& svg': {
width: '100%',
height: 'auto'
}
},
emptyMessage: {
...theme.typography.bodyLongBold,
color: theme.palette.text02,
padding: '0 24px',
textAlign: 'center'
}
};
});
interface IPollListProps {
setCreateMode: (mode: boolean) => void;
}
const PollsList = ({ setCreateMode }: IPollListProps) => {
const { t } = useTranslation();
const { classes, theme } = useStyles();
const { polls } = useSelector((state: IReduxState) => state['features/polls']);
const pollListEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback(() => {
if (pollListEndRef.current) {
// Safari does not support options
const param = browser.isSafari()
? false : {
behavior: 'smooth' as const,
block: 'end' as const,
inline: 'nearest' as const
};
pollListEndRef.current.scrollIntoView(param);
}
}, [ pollListEndRef.current ]);
useEffect(() => {
scrollToBottom();
}, [ polls ]);
const listPolls = Object.keys(polls);
return (
<>
{listPolls.length === 0
? <div className = { classes.container }>
<Icon
className = { classes.emptyIcon }
color = { theme.palette.icon03 }
src = { IconMessage } />
<span className = { classes.emptyMessage }>{t('polls.results.empty')}</span>
</div>
: listPolls.map((id, index) => (
<PollItem
key = { id }
pollId = { id }
ref = { listPolls.length - 1 === index ? pollListEndRef : null }
setCreateMode = { setCreateMode } />
))}
</>
);
};
export default PollsList;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { makeStyles } from 'tss-react/mui';
import Button from '../../../base/ui/components/web/Button';
import AbstractPollsPane, { AbstractProps } from '../AbstractPollsPane';
import PollCreate from './PollCreate';
import PollsList from './PollsList';
/* eslint-enable lines-around-comment */
const useStyles = makeStyles()(() => {
return {
container: {
height: '100%',
position: 'relative'
},
listContainer: {
height: 'calc(100% - 88px)',
overflowY: 'auto'
},
footer: {
position: 'absolute',
bottom: 0,
padding: '24px',
width: '100%',
boxSizing: 'border-box'
}
};
});
const PollsPane = ({ createMode, isCreatePollsDisabled, onCreate, setCreateMode, t }: AbstractProps) => {
const { classes } = useStyles();
return createMode
? <PollCreate setCreateMode = { setCreateMode } />
: <div className = { classes.container }>
<div className = { classes.listContainer } >
<PollsList setCreateMode = { setCreateMode } />
</div>
{ !isCreatePollsDisabled && <div className = { classes.footer }>
<Button
accessibilityLabel = { t('polls.create.create') }
fullWidth = { true }
labelKey = { 'polls.create.create' }
onClick = { onCreate } />
</div>}
</div>;
};
/*
* We apply AbstractPollsPane to fill in the AbstractProps common
* to both the web and native implementations.
*/
// eslint-disable-next-line new-cap
export default AbstractPollsPane(PollsPane);