Start work on Compose page

This commit is contained in:
Marquis Kurt 2019-04-03 20:01:54 -04:00
parent 38a2dfad91
commit 5e679e5a6b
15 changed files with 351 additions and 19 deletions

24
package-lock.json generated
View File

@ -1159,6 +1159,15 @@
"integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==",
"dev": true
},
"@types/emoji-mart": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/@types/emoji-mart/-/emoji-mart-2.8.4.tgz",
"integrity": "sha512-C3J+8nwZxAkgb06+835suFCutyFyz3G4aUFGMlJTvl0ANmE8hj8HUfL92GXNRkJspw+8ciXB6OuwscFFPA56iA==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
@ -5602,6 +5611,15 @@
"minimalistic-crypto-utils": "^1.0.0"
}
},
"emoji-mart": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-2.11.0.tgz",
"integrity": "sha512-ol1ABg0xfDwCeKVu0Mmr/iTRHUMvqlyLZJFRQy+e6aWV83/Y+TEbJpBbtk7NxaW3BLAOyJ8r0NWthRQwxTtT1A==",
"dev": true,
"requires": {
"prop-types": "^15.6.0"
}
},
"emoji-regex": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
@ -6786,6 +6804,12 @@
"escape-string-regexp": "^1.0.5"
}
},
"file-dialog": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/file-dialog/-/file-dialog-0.0.7.tgz",
"integrity": "sha512-eRo8DZbV7G0ADtaUPvGAb5ZXB+jN1ehVeYanIHDt2wEokjiXTy5kzMvtiPw4nEtt08IxJqqMeeFZNP34XLNjRw==",
"dev": true
},
"file-entry-cache": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz",

View File

@ -11,6 +11,7 @@
"@types/react-dom": "16.8.3",
"@types/react-router-dom": "^4.3.1",
"@types/react-swipeable-views": "latest",
"@types/emoji-mart": "^2.8.2",
"megalodon": "^0.6.0",
"moment": "^2.24.0",
"react": "^16.8.6",
@ -21,7 +22,9 @@
"typescript": "3.3.4000",
"notistack": "^0.5.1",
"react-web-share-api": "^0.0.2",
"query-string": "^6.4.2"
"query-string": "^6.4.2",
"file-dialog": "^0.0.7",
"emoji-mart": "^2.8.2"
},
"scripts": {
"start": "BROWSER='Safari Technology Preview' react-scripts start",

View File

@ -14,6 +14,7 @@ import PublicPage from './pages/Public';
import Conversation from './pages/Conversation';
import NotificationsPage from './pages/Notifications';
import SearchPage from './pages/Search';
import Composer from './pages/Compose';
import {withSnackbar} from 'notistack';
let theme = setHyperspaceTheme(getUserDefaultTheme());
@ -53,6 +54,7 @@ class App extends Component<any, any> {
<Route path="/search" component={SearchPage}/>
<Route path="/settings" component={Settings}/>
<Route path="/about" component={AboutPage}/>
<Route path="/compose" component={Composer}/>
</MuiThemeProvider>
);
}

View File

@ -142,5 +142,11 @@ export const styles = (theme: Theme) => createStyles({
marginLeft: 250
},
overflowY: 'auto',
},
composeButton: {
position: "fixed",
bottom: theme.spacing.unit * 2,
right: theme.spacing.unit * 2,
zIndex: 50
}
});

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { Typography, AppBar, Toolbar, IconButton, InputBase, Avatar, ListItemText, Divider, List, ListItem, ListItemIcon, Hidden, Drawer, ListSubheader, ListItemAvatar, withStyles, Menu, MenuItem, ClickAwayListener, Badge } from '@material-ui/core';
import { Typography, AppBar, Toolbar, IconButton, InputBase, Avatar, ListItemText, Divider, List, ListItemIcon, Hidden, Drawer, ListSubheader, ListItemAvatar, withStyles, Menu, MenuItem, ClickAwayListener, Badge } from '@material-ui/core';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
import NotificationsIcon from '@material-ui/icons/Notifications';
@ -10,11 +10,12 @@ import PublicIcon from '@material-ui/icons/Public';
import GroupIcon from '@material-ui/icons/Group';
import SettingsIcon from '@material-ui/icons/Settings';
import InfoIcon from '@material-ui/icons/Info';
import EditIcon from '@material-ui/icons/Edit';
import SupervisedUserCircleIcon from '@material-ui/icons/SupervisedUserCircle';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import {styles} from './AppLayout.styles';
import { UAccount } from '../../types/Account';
import {LinkableListItem, LinkableIconButton} from '../../interfaces/overrides';
import {LinkableListItem, LinkableIconButton, LinkableFab} from '../../interfaces/overrides';
import Mastodon from 'megalodon';
import { Notification } from '../../types/Notification';
import {sendNotificationRequest} from '../../utilities/notifications';
@ -287,6 +288,9 @@ export class AppLayout extends Component<any, IAppLayoutState> {
</Hidden>
</nav>
</div>
<LinkableFab to="/compose" className={classes.composeButton} color="secondary" aria-label="Compose">
<EditIcon/>
</LinkableFab>
</div>
);
}

View File

@ -40,7 +40,7 @@ export const styles = (theme: Theme) => createStyles({
}
},
postEmoji: {
width: theme.typography.body2.fontSize
height: theme.typography.fontSize
},
postMedia: {
height: 0,
@ -76,5 +76,9 @@ export const styles = (theme: Theme) => createStyles({
postTags: {
paddingTop: theme.spacing.unit,
paddingBottom: theme.spacing.unit
},
postAuthorEmoji: {
height: theme.typography.fontSize,
verticalAlign: "middle"
}
});

View File

@ -19,12 +19,12 @@ import { Tag } from '../../types/Tag';
import { Mention } from '../../types/Mention';
import { Visibility } from '../../types/Visibility';
import moment from 'moment';
import { MastodonEmoji } from '../../types/Emojis';
import AttachmentComponent from '../Attachment';
import Mastodon from 'megalodon';
import { LinkableChip, LinkableMenuItem, LinkableIconButton } from '../../interfaces/overrides';
import {withSnackbar} from 'notistack';
import ShareMenu from './PostShareMenu';
import {emojifyString} from '../../utilities/emojis';
interface IPostProps {
post: Status;
@ -61,6 +61,7 @@ export class Post extends React.Component<any, IPostState> {
materializeContent(status: Status) {
const { classes } = this.props;
const oldContent = document.createElement('div');
oldContent.innerHTML = status.content;
@ -72,12 +73,8 @@ export class Post extends React.Component<any, IPostState> {
}
});
if (status.emojis !== undefined && status.emojis.length > 0) {
status.emojis.forEach((emoji: MastodonEmoji) => {
let regexp = new RegExp(':' + emoji.shortcode + ':', 'g');
oldContent.innerHTML = oldContent.innerHTML.replace(regexp, `<img src="${emoji.static_url}" class="${classes.postEmoji}"/>`)
})
}
oldContent.innerHTML = emojifyString(oldContent.innerHTML, status.emojis, classes.postEmoji);
return (
<CardContent className={classes.postContent}>
<div className={classes.mediaContainer}>
@ -144,12 +141,17 @@ export class Post extends React.Component<any, IPostState> {
}
getReblogAuthors(post: Status) {
const { classes } = this.props;
if (post.reblog) {
let author = post.reblog.account;
return `${author.display_name || author.username} (@${author.acct}) 🔄 ${post.account.display_name || post.account.username}`
let origString = `<span>${author.display_name || author.username} (@${author.acct}) 🔄 ${post.account.display_name || post.account.username}</span>`;
let emojis = author.emojis;
emojis.concat(post.account.emojis);
return emojifyString(origString, emojis, classes.postAuthorEmoji);
} else {
let author = post.account;
return `${author.display_name || author.username} (@${author.acct})`
let origString = `<span>${author.display_name || author.username} (@${author.acct})</span>`;
return emojifyString(origString, author.emojis, classes.postAuthorEmoji);
}
}
@ -299,7 +301,10 @@ export class Post extends React.Component<any, IPostState> {
<IconButton key={`${post.id}_submenu`} id={`${post.id}_submenu`} onClick={() => this.togglePostMenu()}>
<MoreVertIcon />
</IconButton>}
title={this.getReblogAuthors(post)} subheader={moment(post.created_at).format("MMMM Do YYYY [at] h:mm A")} />
title={
<Typography dangerouslySetInnerHTML={{__html: this.getReblogAuthors(post)}}></Typography>
}
subheader={moment(post.created_at).format("MMMM Do YYYY [at] h:mm A")} />
{
post.reblog? this.getReblogOfPost(post.reblog): null
}
@ -315,7 +320,7 @@ export class Post extends React.Component<any, IPostState> {
}
<CardActions>
<Tooltip title="Reply">
<LinkableIconButton to={`/compose?reply=${post.id}`}>
<LinkableIconButton to={`/compose?reply=${post.reblog? post.reblog.id: post.id}&visibility=${post.visibility}&acct=${post.reblog? post.reblog.account.acct: post.account.acct}`}>
<ReplyIcon/>
</LinkableIconButton>
</Tooltip>

View File

@ -6,7 +6,6 @@ import * as serviceWorker from './serviceWorker';
import {createUserDefaults, getUserDefaultBool} from './utilities/settings';
import {refreshUserAccountData} from './utilities/accounts';
import {SnackbarProvider} from 'notistack';
import {getNotificationRequestPermission} from './utilities/notifications';
createUserDefaults();
refreshUserAccountData();
@ -16,7 +15,7 @@ ReactDOM.render(
<SnackbarProvider
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
horizontal: 'left',
}}
>
<App />

View File

@ -6,6 +6,7 @@ import Chip, { ChipProps } from '@material-ui/core/Chip';
import { MenuItemProps } from '@material-ui/core/MenuItem';
import { MenuItem } from '@material-ui/core';
import Button, { ButtonProps } from '@material-ui/core/Button';
import Fab, { FabProps } from '@material-ui/core/Fab';
export interface ILinkableListItemProps extends ListItemProps {
to: string;
@ -32,6 +33,11 @@ export interface ILinkableButtonProps extends ButtonProps {
replace?: boolean;
}
export interface ILinkableFabProps extends FabProps {
to: string;
replace?: boolean;
}
export const LinkableListItem = (props: ILinkableListItemProps) => (
<ListItem {...props} component={Link as any}/>
)
@ -52,6 +58,10 @@ export const LinkableButton = (props: ILinkableButtonProps) => (
<Button {...props} component={Link as any}/>
)
export const LinkableFab = (props: ILinkableFabProps) => (
<Fab {...props} component={Link as any}/>
)
export const ProfileRoute = (rest: any, component: Component) => (
<Route {...rest} render={props => (
<Component {...props}/>

View File

@ -0,0 +1,20 @@
import { Theme, createStyles } from "@material-ui/core";
export const styles = (theme: Theme) => createStyles({
dialog: {
minHeight: 400
},
dialogContent: {
paddingBottom: 0
},
dialogActions: {
paddingLeft: theme.spacing.unit * 1.25
},
charsReachingLimit: {
color: theme.palette.error.main
},
warningCaption: {
height: 16,
verticalAlign: "text-bottom"
}
});

215
src/pages/Compose.tsx Normal file
View File

@ -0,0 +1,215 @@
import React, {Component} from 'react';
import { Dialog, DialogContent, DialogActions, withStyles, Button, CardHeader, Avatar, TextField, Toolbar, IconButton, Fade, Typography, Tooltip, Menu, MenuItem } from '@material-ui/core';
import {styles} from './Compose.styles';
import { UAccount } from '../types/Account';
import { Visibility } from '../types/Visibility';
import CameraAltIcon from '@material-ui/icons/CameraAlt';
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 Mastodon from 'megalodon';
import {withSnackbar} from 'notistack';
import { Attachment } from '../types/Attachment';
import { PollWizard } from '../types/Poll';
import filedialog from 'file-dialog';
interface IComposerState {
account: UAccount;
visibility: Visibility;
sensitive: boolean;
sensitiveText?: string;
visibilityMenu: boolean;
text: string;
remainingChars: number;
reply?: string;
acct?: string;
attachments?: [Attachment];
poll?: PollWizard;
}
class Composer extends Component<any, IComposerState> {
client: Mastodon;
constructor(props: any) {
super(props);
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') + "/api/v1");
this.state = {
account: JSON.parse(localStorage.getItem('account') as string),
visibility: "public",
sensitive: false,
visibilityMenu: false,
text: '',
remainingChars: 500
}
}
updateTextFromField(text: string) {
this.setState({ text, remainingChars: 500 - text.length });
}
updateWarningFromField(sensitiveText: string) {
this.setState({ sensitiveText });
}
changeVisibility(visibility: Visibility) {
this.setState({ visibility });
}
uploadMedia() {
filedialog({
multiple: false,
accept: "image/*, video/*"
}).then((media: FileList) => {
let mediaForm = new FormData();
mediaForm.append('file', media[0]);
const uploading = this.props.enqueueSnackbar("Uploading media...", { persist: true })
this.client.post('/media', mediaForm).then((resp: any) => {
let attachment: Attachment = resp.data;
let attachments = this.state.attachments;
if (attachments) {
attachments.push(attachment);
} else {
attachments = [attachment];
}
this.setState({ attachments });
this.props.closeSnackbar(uploading);
this.props.enqueueSnackbar('Media uploaded.');
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't upload media: " + err.name);
})
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't get media: " + err.name);
console.error(err.message);
});
}
getOnlyMediaIds() {
let ids: string[] = [];
if (this.state.attachments) {
this.state.attachments.map((attachment: Attachment) => {
ids.push(attachment.id);
});
}
return ids;
}
post() {
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
}).then(() => {
this.props.enqueueSnackbar('Posted!');
window.history.back();
}).catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't post: " + err.name);
console.log(err.message);
})
}
toggleSensitive() {
this.setState({ sensitive: !this.state.sensitive });
}
toggleVisibilityMenu() {
this.setState({ visibilityMenu: !this.state.visibilityMenu });
}
render() {
const {classes} = this.props;
return (
<Dialog open={true} maxWidth="sm" fullWidth={true} className={classes.dialog} onClose={() => window.history.back()}>
<CardHeader
avatar={
<Avatar src={this.state.account.avatar_static} />
}
title={`${this.state.account.display_name} (@${this.state.account.acct})`}
subheader={this.state.visibility.charAt(0).toUpperCase() + this.state.visibility.substr(1)}
/>
<DialogContent className={classes.dialogContent}>
{
this.state.sensitive?
<Fade in={this.state.sensitive}>
<TextField
variant="outlined"
fullWidth
label="Content warning"
margin="dense"
onChange={(event) => this.updateWarningFromField(event.target.value)}
></TextField>
</Fade>: null
}
{
this.state.visibility === "direct"?
<Typography variant="caption" >
<WarningIcon className={classes.warningCaption}/> Don't forget to add the usernames of the accounts you want to message in your post.
</Typography>: null
}
<TextField
variant="outlined"
multiline
fullWidth
placeholder="What's on your mind?"
margin="normal"
onChange={(event) => this.updateTextFromField(event.target.value)}
inputProps = {
{
maxLength: 500
}
}
/>
<Typography variant="caption" className={this.state.remainingChars <= 100? classes.charsReachingLimit: null}>
{`${this.state.remainingChars} character${this.state.remainingChars === 1? '': 's'} remaining`}
</Typography>
</DialogContent>
<Toolbar className={classes.dialogActions}>
<Tooltip title="Add photos or videos">
<IconButton disabled={this.state.poll !== undefined} onClick={() => this.uploadMedia()} id="compose-media">
<CameraAltIcon/>
</IconButton>
</Tooltip>
<Tooltip title="Insert emoji">
<IconButton id="compose-emoji">
<TagFacesIcon/>
</IconButton>
</Tooltip>
<Tooltip title="Add a poll">
<IconButton disabled={this.state.attachments && this.state.attachments.length > 0} id="compose-poll">
<HowToVoteIcon/>
</IconButton>
</Tooltip>
<Tooltip title="Change who sees your post">
<IconButton id="compose-visibility" onClick={() => this.toggleVisibilityMenu()}>
<VisibilityIcon/>
</IconButton>
</Tooltip>
<Tooltip title="Set a content warning">
<IconButton onClick={() => this.toggleSensitive()} id="compose-warning">
<WarningIcon/>
</IconButton>
</Tooltip>
<Menu open={this.state.visibilityMenu} anchorEl={document.getElementById('compose-visibility')} onClose={() => this.toggleVisibilityMenu()}>
<MenuItem onClick={() => this.changeVisibility('direct')}>Direct (direct message)</MenuItem>
<MenuItem onClick={() => this.changeVisibility('private')}>Private (followers only)</MenuItem>
<MenuItem onClick={() => this.changeVisibility('unlisted')}>Unlisted</MenuItem>
<MenuItem onClick={() => this.changeVisibility('public')}>Public</MenuItem>
</Menu>
</Toolbar>
<DialogActions>
<Button color="secondary" onClick={() => this.post()}>Post</Button>
</DialogActions>
</Dialog>
)
}
}
export default withStyles(styles)(withSnackbar(Composer));

View File

@ -98,6 +98,9 @@ export const styles = (theme: Theme) => createStyles({
marginBottom: theme.spacing.unit,
backgroundColor: theme.palette.primary.main
},
pageProfileNameEmoji: {
height: theme.typography.h4.fontSize,
},
pageProfileStatsDiv: {
display: 'inline-flex',
marginTop: theme.spacing.unit * 2,
@ -153,5 +156,5 @@ export const styles = (theme: Theme) => createStyles({
top: 116,
right: theme.spacing.unit * 24,
}
}
},
});

View File

@ -8,6 +8,7 @@ import { Relationship } from '../types/Relationship';
import Post from '../components/Post';
import {withSnackbar} from 'notistack';
import { LinkableButton } from '../interfaces/overrides';
import { emojifyString } from '../utilities/emojis';
interface IProfilePageState {
account?: Account;
@ -210,7 +211,7 @@ class ProfilePage extends Component<any, IProfilePageState> {
<div className={classes.pageHeroBackgroundImage} style={{ backgroundImage: this.state.account? `url("${this.state.account.header}")`: `url("")`}}/>
<div className={classes.pageHeroContent}>
<Avatar className={classes.pageProfileAvatar} src={this.state.account ? this.state.account.avatar: ""}/>
<Typography variant="h4" color="inherit">{this.state.account ? this.state.account.display_name: ""}</Typography>
<Typography variant="h4" color="inherit" dangerouslySetInnerHTML={{__html: this.state.account? emojifyString(this.state.account.display_name, this.state.account.emojis, classes.pageProfileNameEmoji): ""}}></Typography>
<Typography variant="caption" color="inherit">{this.state.account ? '@' + this.state.account.acct: ""}</Typography>
<Typography paragraph color="inherit">{this.state.account ? this.state.account.note: ""}</Typography>
<Divider/>

View File

@ -17,4 +17,14 @@ export type Poll = {
export type PollOption = {
title: string;
votes_count: number | null;
}
export type PollWizard = {
expires_at: string;
multiple: boolean;
options: PollWizardOption[];
}
export type PollWizardOption = {
title: string;
}

26
src/utilities/emojis.tsx Normal file
View File

@ -0,0 +1,26 @@
import {MastodonEmoji} from '../types/Emojis';
// if (status.emojis !== undefined && status.emojis.length > 0) {
// status.emojis.forEach((emoji: MastodonEmoji) => {
// let regexp = new RegExp(':' + emoji.shortcode + ':', 'g');
// oldContent.innerHTML = oldContent.innerHTML.replace(regexp, `<img src="${emoji.static_url}" class="${classes.postEmoji}"/>`)
// })
// }
/**
* Takes a given string and replaces emoji codes with their respective image tags.
* @param contents The string to replace with emojis
* @param emojis The set of emojis to replace the content with
* @param className The associated class for the string
* @returns String with image tags for emojis
*/
export function emojifyString(contents: string, emojis: [MastodonEmoji], className?: any): string {
let newContents: string = contents;
emojis.forEach((emoji: MastodonEmoji) => {
let filter = new RegExp(`:${emoji.shortcode}:`, 'g');
newContents = newContents.replace(filter, `<img src=${emoji.static_url} ${className? `class="${className}"`: ""}/>`)
})
return newContents;
}