Fix streaming, add poll support to composer

This commit is contained in:
Marquis Kurt 2019-04-06 13:41:43 -04:00
parent 27b6c92c22
commit 4978af6b6e
10 changed files with 359 additions and 20 deletions

53
package-lock.json generated
View File

@ -944,6 +944,21 @@
"integrity": "sha512-5a6wqoJV/xEdbRNKVo6I4hO3VjyDq//8q2f9I6PBAvMesJHFauXDorcNCsr9RzvsZnaWi5NYCcfyqP1QeFHFbw==",
"dev": true
},
"@date-io/core": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.1.0.tgz",
"integrity": "sha512-PyjhyR2fbp7Q8xpB5zoOyT3dqr8Bn4kXfREf1w6AnQalwdftNxChB2/p88fI1qsx8KNmHDJY12eCgLoZaPegTw==",
"dev": true
},
"@date-io/moment": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@date-io/moment/-/moment-1.1.0.tgz",
"integrity": "sha512-U7Vrhk7nlrKMtlLuNs7DAWmMsMBTgWFAvgpKg6Z5Opo/U7ab5DvI8nLks5dqJbeEFQDSlO+KCmJWfsL9hVI3Jw==",
"dev": true,
"requires": {
"@date-io/core": "^1.1.0"
}
},
"@material-ui/core": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/@material-ui/core/-/core-3.9.3.tgz",
@ -1290,6 +1305,15 @@
"@types/react": "*"
}
},
"@types/react-text-mask": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/@types/react-text-mask/-/react-text-mask-5.4.4.tgz",
"integrity": "sha512-mnwyDgUwFtJVAZ8f+tzPGmYjpH7TLXxHSGty338abca6aAjdjRLCOC4h+CxlvB8xrVAIU5pkNllpHhfJCA3hXQ==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-transition-group": {
"version": "2.0.16",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.0.16.tgz",
@ -4152,6 +4176,12 @@
"shallow-clone": "^0.1.2"
}
},
"clsx": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.0.3.tgz",
"integrity": "sha512-xLoSw6DMp7YvbEeLrQJBcWWRRerdHrU1WHoL1hYJOKUeDpVMRq7pv7NI2JHQbCRAe5ptINNzhdYmtfN6MsdCUw==",
"dev": true
},
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -10806,6 +10836,20 @@
"object-visit": "^1.0.0"
}
},
"material-ui-pickers": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/material-ui-pickers/-/material-ui-pickers-2.2.4.tgz",
"integrity": "sha512-QCQh08Ylmnt+o4laW+rPs92QRAcESv3sPXl50YadLm++rAZAXAOh3K8lreGdynCMYFgZfdyu81Oz9xzTlAZNfw==",
"dev": true,
"requires": {
"@types/react-text-mask": "^5.4.3",
"clsx": "^1.0.2",
"react-event-listener": "^0.6.6",
"react-text-mask": "^5.4.3",
"react-transition-group": "^2.5.3",
"tslib": "^1.9.3"
}
},
"math-random": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz",
@ -15315,6 +15359,15 @@
}
}
},
"react-text-mask": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/react-text-mask/-/react-text-mask-5.4.3.tgz",
"integrity": "sha1-mR77QpnjDC5sLEbRP2FxaUY+DS0=",
"dev": true,
"requires": {
"prop-types": "^15.5.6"
}
},
"react-transition-group": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.7.1.tgz",

View File

@ -24,7 +24,9 @@
"react-web-share-api": "^0.0.2",
"query-string": "^6.4.2",
"file-dialog": "^0.0.7",
"emoji-mart": "^2.8.2"
"emoji-mart": "^2.8.2",
"material-ui-pickers": "^2.2.4",
"@date-io/moment": "^1.1.0"
},
"scripts": {
"start": "BROWSER='Safari Technology Preview' react-scripts start",

View File

@ -254,7 +254,7 @@ export class AppLayout extends Component<any, IAppLayoutState> {
<ListItemText primary={this.state.currentUser.display_name || this.state.currentUser.acct} secondary={'@' + this.state.currentUser.acct}/>
</LinkableListItem>
<Divider/>
<MenuItem>Switch account</MenuItem>
{/* <MenuItem>Switch account</MenuItem> */}
<MenuItem>Log out</MenuItem>
</div>
</ClickAwayListener>

View File

@ -80,5 +80,8 @@ export const styles = (theme: Theme) => createStyles({
postAuthorEmoji: {
height: theme.typography.fontSize,
verticalAlign: "middle"
},
heading: {
color: "inherit"
}
});

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Typography, IconButton, Card, CardHeader, Avatar, CardContent, CardActions, withStyles, Menu, MenuItem, Chip, Divider, CardMedia, CardActionArea, ExpansionPanel, ExpansionPanelSummary, ExpansionPanelDetails, Zoom, Tooltip } from '@material-ui/core';
import { Typography, IconButton, Card, CardHeader, Avatar, CardContent, CardActions, withStyles, Menu, MenuItem, Chip, Divider, CardMedia, CardActionArea, ExpansionPanel, ExpansionPanelSummary, ExpansionPanelDetails, Zoom, Tooltip, RadioGroup, Radio, FormControlLabel, Button } from '@material-ui/core';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import ReplyIcon from '@material-ui/icons/Reply';
import FavoriteIcon from '@material-ui/icons/Favorite';
@ -25,6 +25,7 @@ import { LinkableChip, LinkableMenuItem, LinkableIconButton } from '../../interf
import {withSnackbar} from 'notistack';
import ShareMenu from './PostShareMenu';
import {emojifyString} from '../../utilities/emojis';
import { PollOption, Poll } from '../../types/Poll';
interface IPostProps {
post: Status;
@ -36,6 +37,7 @@ interface IPostState {
post: Status;
media_slides?: number;
menuIsOpen: boolean;
myVote?: [number];
}
export class Post extends React.Component<any, IPostState> {
@ -59,6 +61,54 @@ export class Post extends React.Component<any, IPostState> {
this.setState({ menuIsOpen: !this.state.menuIsOpen })
}
findBiggestVote() {
let poll = this.state.post.poll;
let votes: number[] = [];
let bv = "";
if (poll) {
poll.options.forEach((option: PollOption) => {
votes.push(option.votes_count? option.votes_count: 0);
});
let biggestVote = Math.max.apply(null, votes);
poll.options.forEach((option: PollOption) => {
if (option.votes_count === biggestVote) {
bv = option.title;
};
});
return bv;
} else {
return "No poll option was the best.";
}
}
captureVote(option: any) {
let poll = this.state.post.poll;
let pollIndex: number = 0;
if (poll) {
poll.options.forEach((pollOption: PollOption, index: number) => {
if (pollOption.title === option) {
pollIndex = index;
}
})
}
this.setState({ myVote: [pollIndex] });
}
submitVote() {
let poll = this.state.post.poll;
if (poll) {
this.client.post(`/polls/${poll.id}/votes`, {choices: this.state.myVote}).then((resp: any) => {
let post = this.state.post;
post.poll = resp.data;
this.setState({ post });
this.props.enqueueSnackbar("Vote submitted.");
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't vote: " + err.name);
console.error(err.message);
})
}
}
materializeContent(status: Status) {
const { classes } = this.props;
@ -105,22 +155,81 @@ export class Post extends React.Component<any, IPostState> {
<AttachmentComponent media={status.media_attachments}/>:
<span/>
}
{
status.poll?
status.poll.voted || status.poll.expired?
<div>
<Typography variant="caption">You can't vote on this poll. Below are the results of the poll.</Typography>
<RadioGroup
value={this.findBiggestVote()}
>
{
status.poll.options.map((pollOption: PollOption) => {
let x = <FormControlLabel
disabled
value={pollOption.title}
control={<Radio />}
label={`${pollOption.title} (${pollOption.votes_count} votes)`}
key={pollOption.title+pollOption.votes_count}
/>;
return (x);
})
}
</RadioGroup>
{
status.poll && status.poll.expired?
<Typography variant="caption">This poll has expired.</Typography>:
<Typography variant="caption">This poll will expire on {moment(status.poll.expires_at? status.poll.expires_at: "").format('MMMM Do YYYY, [at] h:mm A')}.</Typography>
}
</div>:
<div>
<RadioGroup
onChange={(event: any, option: any) => this.captureVote(option)}
>
{
status.poll.options.map((pollOption: PollOption) => {
let x = <FormControlLabel
value={pollOption.title}
control={<Radio />}
label={pollOption.title}
key={pollOption.title+pollOption.votes_count}
/>;
return (x);
})
}
</RadioGroup>
<Button color="primary" onClick={(event: any) => this.submitVote()}>Vote</Button>
</div>: null
}
</div>
</CardContent>
);
}
spoilerContainsFlags(text: string): boolean {
let unsafeFlags = ["NSFW", "nsfw", "lewd", "sex"];
let result: boolean = false;
unsafeFlags.forEach((flag: string) => {
if (text.includes(flag)) {
result = true;
}
})
return result;
}
getSensitiveContent(spoiler_text: string, content: Status) {
const { classes } = this.props;
const warningText = spoiler_text || "Unmarked content";
let icon;
if (spoiler_text.includes("NSFW") || spoiler_text.includes("Spoiler") || warningText === "Unmarked content") {
if (this.spoilerContainsFlags(spoiler_text) || spoiler_text.includes("Spoiler") || warningText === "Unmarked content") {
icon = <WarningIcon className={classes.postWarningIcon}/>;
}
return (
<ExpansionPanel className={spoiler_text.includes("NSFW")? classes.nsfwCard: classes.spoilerCard} color="inherit">
<ExpansionPanel className={this.spoilerContainsFlags(spoiler_text)? classes.nsfwCard: classes.spoilerCard} color="inherit">
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon/>} color="inherit">
{icon}<Typography className={classes.heading} color="inherit">{warningText}</Typography>
{icon}<Typography>{warningText}</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.postContent} color="inherit">
{this.materializeContent(content)}

View File

@ -35,5 +35,14 @@ export const styles = (theme: Theme) => createStyles({
[theme.breakpoints.up('sm')]: {
display: "block"
}
},
pollWizardOptionIcon: {
marginRight: theme.spacing.unit * 2,
marginTop: 4,
marginBottom: 4,
color: theme.palette.grey[700]
},
pollWizardFlexGrow: {
flexGrow: 1
}
});

View File

@ -9,14 +9,17 @@ import TagFacesIcon from '@material-ui/icons/TagFaces';
import HowToVoteIcon from '@material-ui/icons/HowToVote';
import VisibilityIcon from '@material-ui/icons/Visibility';
import WarningIcon from '@material-ui/icons/Warning';
import DeleteIcon from '@material-ui/icons/Delete';
import RadioButtonCheckedIcon from '@material-ui/icons/RadioButtonChecked';
import Mastodon from 'megalodon';
import {withSnackbar} from 'notistack';
import { Attachment } from '../types/Attachment';
import { PollWizard } from '../types/Poll';
import { PollWizard, PollWizardOption } from '../types/Poll';
import filedialog from 'file-dialog';
import ComposeMediaAttachment from '../components/ComposeMediaAttachment';
import EmojiPicker from '../components/EmojiPicker';
import { DateTimePicker, MuiPickersUtilsProvider } from 'material-ui-pickers';
import MomentUtils from '@date-io/moment';
interface IComposerState {
account: UAccount;
@ -30,6 +33,7 @@ interface IComposerState {
acct?: string;
attachments?: [Attachment];
poll?: PollWizard;
pollExpiresDate?: any;
showEmojis: boolean;
}
@ -200,14 +204,120 @@ class Composer extends Component<any, IComposerState> {
}
}
createPoll() {
if (this.state.poll === undefined) {
let expiration = new Date();
let current = new Date();
expiration.setMinutes(expiration.getMinutes() + 30);
let expiryDifference = (expiration.getTime() - current.getTime() / 1000);
let temporaryPoll: PollWizard = {
expires_at: expiryDifference.toString(),
multiple: false,
options: [{title: 'Option 1'}, {title: 'Option 2'}]
}
this.setState({
poll: temporaryPoll,
pollExpiresDate: expiration
});
}
}
addPollItem() {
if (this.state.poll !== undefined && this.state.poll.options.length < 4) {
let newOption = {title: 'New option'}
let options = this.state.poll.options;
let poll = this.state.poll;
options.push(newOption);
poll.options = options;
poll.multiple = true;
this.setState({
poll: poll
})
} else if (this.state.poll && this.state.poll.options.length == 4) {
this.props.enqueueSnackbar("You've reached the options limit in your poll.", { variant: 'error' })
}
}
editPollItem(position: number, newTitle: any) {
if (this.state.poll !== undefined) {
let poll = this.state.poll;
let options = this.state.poll.options;
options.forEach((option: PollWizardOption) => {
if (position === options.indexOf(option)) {
option.title = newTitle.target.value;
}
});
poll.options = options;
this.setState({
poll: poll
});
this.props.enqueueSnackbar('Option edited.');
}
}
removePollItem(item: string) {
if (this.state.poll !== undefined && this.state.poll.options.length > 2) {
let options = this.state.poll.options;
let poll = this.state.poll;
options.forEach((option: PollWizardOption) => {
if (item === option.title) {
options.splice(options.indexOf(option), 1);
}
});
poll.options = options;
if (options.length === 2) {
poll.multiple = false;
}
this.setState({
poll: poll
})
} else if (this.state.poll && this.state.poll.options.length <= 2) {
this.props.enqueueSnackbar('Polls must have at least two items.', { variant: 'error'} );
}
}
setPollExpires(date: string) {
let currentDate = new Date();
let newDate = new Date(date);
let poll = this.state.poll;
if (poll) {
let expiry = ((newDate.getTime() - currentDate.getTime()) / 1000);
console.log(expiry);
if (expiry >= 1800) {
poll.expires_at = expiry.toString();
this.setState({ poll, pollExpiresDate: date });
this.props.enqueueSnackbar("Expiration updated.")
} else {
this.props.enqueueSnackbar("Expiration is too small (min. 30 minutes).", { variant: 'error' });
}
}
}
removePoll() {
this.setState({
poll: undefined
});
}
post() {
let pollOptions: string[] = [];
if (this.state.poll) {
this.state.poll.options.forEach((option: PollWizardOption) => {
pollOptions.push(option.title);
})
}
this.client.post('/statuses', {
status: this.state.text,
media_ids: this.getOnlyMediaIds(),
visibility: this.state.visibility,
sensitive: this.state.sensitive,
spoiler_text: this.state.sensitiveText,
in_reply_to_id: this.state.reply
in_reply_to_id: this.state.reply,
poll: this.state.poll? {
options: pollOptions,
expires_in: this.state.poll.expires_at,
multiple: this.state.poll.multiple
}: null
}).then(() => {
this.props.enqueueSnackbar('Posted!');
window.history.back();
@ -299,6 +409,44 @@ class Composer extends Component<any, IComposerState> {
</GridList>
</div>: null
}
{
this.state.poll?
<div style={{ marginTop: 4}}>
{
this.state.poll?
this.state.poll.options.map((option: PollWizardOption, index: number) => {
let c = <div style={{ display: "flex" }} key={"compose_option_" + index.toString()}>
<RadioButtonCheckedIcon className={classes.pollWizardOptionIcon}/>
<TextField
onBlur={(event: any) => this.editPollItem(index, event)}
defaultValue={option.title}/>
<div className={classes.pollWizardFlexGrow}/>
<Tooltip title="Remove poll option">
<IconButton onClick={() => this.removePollItem(option.title)}>
<DeleteIcon/>
</IconButton>
</Tooltip>
</div>
return c;
}): null
}
<div style={{ display: "flex"}}>
<MuiPickersUtilsProvider utils={MomentUtils}>
<DateTimePicker
value={this.state.pollExpiresDate? this.state.pollExpiresDate: new Date()}
onChange={(date: any) => {
this.setPollExpires(date.toISOString());
}}
label="Poll exipres on"
disablePast
/>
</MuiPickersUtilsProvider>
<div className={classes.pollWizardFlexGrow}/>
<Button onClick={() => this.addPollItem()}>Add Option</Button>
</div>
</div>: null
}
</DialogContent>
<Toolbar className={classes.dialogActions}>
<Tooltip title="Add photos or videos">
@ -320,7 +468,11 @@ class Composer extends Component<any, IComposerState> {
<EmojiPicker onGetEmoji={(emoji: any) => this.insertEmoji(emoji)}/>
</Menu>
<Tooltip title="Add a poll">
<IconButton disabled={this.state.attachments && this.state.attachments.length > 0} id="compose-poll">
<IconButton disabled={this.state.attachments && this.state.attachments.length > 0} id="compose-poll" onClick={() => {
this.state.poll?
this.removePoll():
this.createPoll()
}}>
<HowToVoteIcon/>
</IconButton>
</Tooltip>

View File

@ -3,7 +3,7 @@ import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar,
import {styles} from './PageLayout.styles';
import Post from '../components/Post';
import { Status } from '../types/Status';
import Mastodon from 'megalodon';
import Mastodon, { StreamListener } from 'megalodon';
import {withSnackbar} from 'notistack';
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
@ -20,7 +20,7 @@ interface IHomePageState {
class HomePage extends Component<any, IHomePageState> {
client: Mastodon;
streamListener: any;
streamListener: StreamListener;
constructor(props: any) {
super(props);
@ -31,11 +31,11 @@ class HomePage extends Component<any, IHomePageState> {
}
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.streamListener = this.client.stream('/streaming/user');
}
componentWillMount() {
this.streamListener = this.client.stream('/streaming/user');
this.streamListener.on('connect', () => {
this.client.get('/timelines/home', {limit: 40}).then((resp: any) => {
@ -92,6 +92,10 @@ class HomePage extends Component<any, IHomePageState> {
})
}
componentWillUnmount() {
this.streamListener.stop();
}
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;

View File

@ -3,7 +3,7 @@ import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar,
import {styles} from './PageLayout.styles';
import Post from '../components/Post';
import { Status } from '../types/Status';
import Mastodon from 'megalodon';
import Mastodon, { StreamListener } from 'megalodon';
import {withSnackbar} from 'notistack';
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
@ -20,7 +20,7 @@ interface ILocalPageState {
class LocalPage extends Component<any, ILocalPageState> {
client: Mastodon;
streamListener: any;
streamListener: StreamListener;
constructor(props: any) {
super(props);
@ -31,11 +31,11 @@ class LocalPage extends Component<any, ILocalPageState> {
}
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.streamListener = this.client.stream('/streaming/public/local');
}
componentWillMount() {
this.streamListener = this.client.stream('/streaming/public/local');
this.streamListener.on('connect', () => {
this.client.get('/timelines/public', {limit: 40, local: true}).then((resp: any) => {
@ -92,6 +92,10 @@ class LocalPage extends Component<any, ILocalPageState> {
})
}
componentWillUnmount() {
this.streamListener.stop();
}
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;

View File

@ -3,7 +3,7 @@ import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar,
import {styles} from './PageLayout.styles';
import Post from '../components/Post';
import { Status } from '../types/Status';
import Mastodon from 'megalodon';
import Mastodon, { StreamListener } from 'megalodon';
import {withSnackbar} from 'notistack';
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
@ -20,7 +20,7 @@ interface IPublicPageState {
class PublicPage extends Component<any, IPublicPageState> {
client: Mastodon;
streamListener: any;
streamListener: StreamListener;
constructor(props: any) {
super(props);
@ -31,12 +31,11 @@ class PublicPage extends Component<any, IPublicPageState> {
}
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
this.streamListener = this.client.stream('/streaming/public');
}
componentWillMount() {
this.streamListener = this.client.stream('/streaming/public');
this.streamListener.on('connect', () => {
this.client.get('/timelines/public', {limit: 40}).then((resp: any) => {
let statuses: [Status] = resp.data;
@ -92,6 +91,10 @@ class PublicPage extends Component<any, IPublicPageState> {
})
}
componentWillUnmount() {
this.streamListener.stop();
}
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;