Add tags, remove some anchors, and put new posts behind 'View more'

This commit is contained in:
Marquis Kurt 2019-03-30 21:20:55 -04:00
parent b8f9035be1
commit 51ed21c39c
12 changed files with 606 additions and 91 deletions

View File

@ -9,9 +9,9 @@ import Settings from './pages/Settings';
import { getUserDefaultBool, getUserDefaultTheme } from './utilities/settings';
import ProfilePage from './pages/ProfilePage';
import HomePage from './pages/Home';
import LocalPage from './pages/Local';
import PublicPage from './pages/Public';
import {withSnackbar} from 'notistack';
import {ProfileRoute} from './interfaces/overrides';
let theme = setHyperspaceTheme(getUserDefaultTheme());
class App extends Component<any, any> {
@ -40,8 +40,8 @@ class App extends Component<any, any> {
<AppLayout/>
<Route exact path="/" component={HomePage}/>
<Route path="/home" component={HomePage}/>
<Route path="/local"/>
<Route path="/public"/>
<Route path="/local" component={LocalPage}/>
<Route path="/public" component={PublicPage}/>
<Route path="/messages"/>
<Route path="/notifications"/>
<Route path="/profile/:profileId" render={props => <ProfilePage {...props}></ProfilePage>}/>

View File

@ -10,6 +10,7 @@ export const styles = (theme: Theme) => createStyles({
'&:hover': {
backgroundColor: theme.palette.secondary.light
},
backgroundColor: theme.palette.secondary.main,
marginBottom: theme.spacing.unit
},
postContent: {
@ -21,6 +22,16 @@ export const styles = (theme: Theme) => createStyles({
'&:hover': {
textDecoration: 'underline'
},
'&.u-url.mention': {
textDecoration: 'none',
color: 'inherit',
fontWeight: 'bold'
},
'&.mention.hashtag': {
textDecoration: 'none',
color: 'inherit',
fontWeight: 'bold'
}
}
},
postCard: {
@ -54,5 +65,9 @@ export const styles = (theme: Theme) => createStyles({
},
postDidAction: {
color: theme.palette.secondary.main
},
postMention: {
marginRight: theme.spacing.unit,
marginBottom: theme.spacing.unit
}
});

View File

@ -12,8 +12,10 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import GroupIcon from '@material-ui/icons/Group';
import ForumIcon from '@material-ui/icons/Forum';
import AlternateEmailIcon from '@material-ui/icons/AlternateEmail';
import LocalOfferIcon from '@material-ui/icons/LocalOffer';
import {styles} from './Post.styles';
import { Status } from '../../types/Status';
import { Tag } from '../../types/Tag';
import { Mention } from '../../types/Mention';
import { Visibility } from '../../types/Visibility';
import moment from 'moment';
@ -61,6 +63,15 @@ export class Post extends React.Component<any, IPostState> {
const { classes } = this.props;
const oldContent = document.createElement('div');
oldContent.innerHTML = status.content;
let anchors = oldContent.getElementsByTagName("a");
Array.prototype.forEach.call(anchors, (link: HTMLAnchorElement) => {
if (link.className.includes("mention") || link.className.includes("hashtag")) {
link.removeAttribute("href");
}
});
if (status.emojis !== undefined && status.emojis.length > 0) {
status.emojis.forEach((emoji: MastodonEmoji) => {
let regexp = new RegExp(':' + emoji.shortcode + ':', 'g');
@ -77,7 +88,7 @@ export class Post extends React.Component<any, IPostState> {
<CardActionArea href={status.card.url} target="_blank" rel="noreferrer">
<CardContent>
<Typography gutterBottom variant="h6" component="h2">{status.card.title}</Typography>
<Typography>{status.card.description}</Typography>
<Typography>{status.card.description || "No description provided. Click with caution."}</Typography>
</CardContent>
{
status.card.image?
@ -124,17 +135,6 @@ export class Post extends React.Component<any, IPostState> {
if (of !== null) {
return (
<CardContent className={classes.postContent}>
<LinkableChip
avatar={
<Avatar alt={of.account.acct} src={of.account.avatar_static}/>
}
className={classes.postReblogChip}
label={`@${of.account.username} posted:`}
key={of.id + "_reblog_chip_" + of.account.id}
to={`/profile/${of.account.id}`}
replace={true}
clickable
/>
{of.sensitive? this.getSensitiveContent(of.spoiler_text, of): this.materializeContent(of)}
</CardContent>
);
@ -143,13 +143,25 @@ export class Post extends React.Component<any, IPostState> {
}
}
getReblogAuthors(post: Status) {
if (post.reblog) {
let author = post.reblog.account;
return `${author.display_name || author.username} (@${author.acct}) 🔄 ${post.account.display_name || post.account.username}`
} else {
let author = post.account;
return `${author.display_name || author.username} (@${author.acct})`
}
}
getMentions(mention: [Mention]) {
const { classes } = this.props;
if (mention.length > 0) {
return (
<CardContent>
<Typography variant="caption">Mentions</Typography>
{
this.state.post.mentions.map((person: Mention) => {
mention.map((person: Mention) => {
return <LinkableChip
avatar={
<Avatar>
@ -159,6 +171,36 @@ export class Post extends React.Component<any, IPostState> {
label={person.username}
key={this.state.post.id + "_mention_" + person.id}
to={`/profile/${person.id}`}
className={classes.postMention}
clickable
/>
})
}
</CardContent>
)
} else {
return null;
}
}
getTags(tags: [Tag]) {
const { classes } = this.props;
if (tags.length > 0) {
return (
<CardContent>
<Typography variant="caption">Tags</Typography>
{
tags.map((tag: Tag) => {
return <LinkableChip
avatar={
<Avatar>
<LocalOfferIcon/>
</Avatar>
}
label={tag.name}
key={this.state.post.id + "_tag_" + tag.name}
to={`/search?tag=${tag.name}`}
className={classes.postMention}
clickable
/>
})
@ -245,76 +287,79 @@ export class Post extends React.Component<any, IPostState> {
render() {
const { classes } = this.props;
const post = this.state.post;
return (
<Zoom in={true}>
<Card className={classes.post}>
<CardHeader avatar={<Avatar src={this.state.post.account.avatar_static} />} action={
<IconButton key={`${this.state.post.id}_submenu`} id={`${this.state.post.id}_submenu`} onClick={() => this.togglePostMenu()}>
<CardHeader avatar={
<Avatar src={
post.reblog? post.reblog.account.avatar_static: post.account.avatar_static
} />
} action={
<IconButton key={`${post.id}_submenu`} id={`${post.id}_submenu`} onClick={() => this.togglePostMenu()}>
<MoreVertIcon />
</IconButton>}
title={
`${this.state.post.account.display_name || this.state.post.account.username} (@${this.state.post.account.acct})`
} subheader={moment(this.state.post.created_at).format("MMMM Do YYYY [at] h:mm A")} />
title={this.getReblogAuthors(post)} subheader={moment(post.created_at).format("MMMM Do YYYY [at] h:mm A")} />
{
this.state.post.reblog? this.getReblogOfPost(this.state.post.reblog): null
post.reblog? this.getReblogOfPost(post.reblog): null
}
{
this.state.post.sensitive? this.getSensitiveContent(this.state.post.spoiler_text, this.state.post):
post.sensitive? this.getSensitiveContent(post.spoiler_text, post):
<CardContent className={classes.postContent}>
{this.state.post.reblog? null: this.materializeContent(this.state.post)}
{post.reblog? null: this.materializeContent(post)}
</CardContent>
}
{
this.getMentions(this.state.post.mentions)
post.reblog && post.reblog.mentions.length > 0? this.getMentions(post.reblog.mentions): this.getMentions(post.mentions)
}
{
this.state.post.reblog && this.state.post.reblog.mentions.length > 0? this.getMentions(this.state.post.reblog.mentions): <span/>
post.reblog && post.reblog.tags.length > 0? this.getTags(post.reblog.tags): this.getTags(post.tags)
}
<CardActions>
<Tooltip title="Reply">
<LinkableIconButton to={`/compose?reply=${this.state.post.id}`}>
<LinkableIconButton to={`/compose?reply=${post.id}`}>
<ReplyIcon/>
</LinkableIconButton>
</Tooltip>
<Typography>{this.state.post.replies_count}</Typography>
<Typography>{post.replies_count}</Typography>
<Tooltip title="Favorite">
<IconButton onClick={() => this.toggleFavorited(this.state.post)}>
<FavoriteIcon className={this.state.post.favourited? classes.postDidAction: ''}/>
<IconButton onClick={() => this.toggleFavorited(post)}>
<FavoriteIcon className={post.favourited? classes.postDidAction: ''}/>
</IconButton>
</Tooltip>
<Typography>{this.state.post.favourites_count}</Typography>
<Typography>{post.favourites_count}</Typography>
<Tooltip title="Boost">
<IconButton onClick={() => this.toggleReblogged(this.state.post)}>
<AutorenewIcon className={this.state.post.reblogged? classes.postDidAction: ''}/>
<IconButton onClick={() => this.toggleReblogged(post)}>
<AutorenewIcon className={post.reblogged? classes.postDidAction: ''}/>
</IconButton>
</Tooltip>
<Typography>{this.state.post.reblogs_count}</Typography>
<Typography>{post.reblogs_count}</Typography>
<Tooltip title="View thread">
<LinkableIconButton to={`/conversation/${this.state.post.id}`}>
<LinkableIconButton to={`/conversation/${post.id}`}>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip title="Open in Web">
<IconButton href={this.getMastodonUrl(this.state.post)} rel="noreferrer" target="_blank">
<IconButton href={this.getMastodonUrl(post)} rel="noreferrer" target="_blank">
<OpenInNewIcon />
</IconButton>
</Tooltip>
<div className={classes.postFlexGrow} />
<div className={classes.postTypeIconDiv}>
{this.showVisibilityIcon(this.state.post.visibility)}
{this.showVisibilityIcon(post.visibility)}
</div>
</CardActions>
<Menu
id="postmenu"
anchorEl={document.getElementById(`${this.state.post.id}_submenu`)}
anchorEl={document.getElementById(`${post.id}_submenu`)}
open={this.state.menuIsOpen}
onClose={() => this.togglePostMenu()}
>
<ShareMenu config={{
params: {
title: `@${this.state.post.account.username} posted on Mastodon: `,
text: this.state.post.content,
url: this.getMastodonUrl(this.state.post),
title: `@${post.account.username} posted on Mastodon: `,
text: post.content,
url: this.getMastodonUrl(post),
},
onShareSuccess: () => this.props.enqueueSnackbar("Post shared!", {variant: 'success'}),
onShareError: (error: Error) => {
@ -322,9 +367,13 @@ export class Post extends React.Component<any, IPostState> {
this.props.enqueueSnackbar(`Couldn't share post: ${error.name}`, {variant: 'error'})
},
}}/>
<LinkableMenuItem to={`/profile/${this.state.post.account.id}`}>View author profile</LinkableMenuItem>
{
this.state.post.account.id == JSON.parse(localStorage.getItem('account') as string).id?
post.reblog?
<LinkableMenuItem to={`/profile/${post.reblog.account.id}`}>View author profile</LinkableMenuItem>: null
}
<LinkableMenuItem to={`/profile/${post.account.id}`}>View reblogger profile</LinkableMenuItem>
{
post.account.id == JSON.parse(localStorage.getItem('account') as string).id?
<div>
<Divider/>
<MenuItem>Delete</MenuItem>

View File

@ -5,6 +5,7 @@ import { Link, Route } from "react-router-dom";
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';
export interface ILinkableListItemProps extends ListItemProps {
to: string;
@ -26,6 +27,11 @@ export interface ILinkableMenuItemProps extends MenuItemProps {
replace?: boolean;
}
export interface ILinkableButtonProps extends ButtonProps {
to: string;
replace?: boolean;
}
export const LinkableListItem = (props: ILinkableListItemProps) => (
<ListItem {...props} component={Link as any}/>
)
@ -42,8 +48,13 @@ export const LinkableMenuItem = (props: ILinkableMenuItemProps) => (
<MenuItem {...props} component={Link as any}/>
)
export const LinkableButton = (props: ILinkableButtonProps) => (
<Button {...props} component={Link as any}/>
)
export const ProfileRoute = (rest: any, component: Component) => (
<Route {...rest} render={props => (
<Component {...props}/>
)}/>
)
)

View File

@ -1,13 +1,15 @@
import React, { Component } from 'react';
import { withStyles, CircularProgress, Typography, Paper, Button} from '@material-ui/core';
import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide} from '@material-ui/core';
import {styles} from './PageLayout.styles';
import Post from '../components/Post';
import { Status } from '../types/Status';
import Mastodon from 'megalodon';
import {withSnackbar} from 'notistack';
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
interface IHomePageState {
posts?: [Status];
backlogPosts?: [Status] | null;
viewIsLoading: boolean;
viewDidLoad?: boolean;
viewDidError?: boolean;
@ -24,7 +26,8 @@ class HomePage extends Component<any, IHomePageState> {
super(props);
this.state = {
viewIsLoading: true
viewIsLoading: true,
backlogPosts: null
}
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
@ -32,7 +35,7 @@ class HomePage extends Component<any, IHomePageState> {
}
componentWillMount() {
this.streamListener = this.client.stream('/streaming/home');
this.streamListener = this.client.stream('/streaming/user');
this.streamListener.on('connect', () => {
this.client.get('/timelines/home', {limit: 40}).then((resp: any) => {
@ -57,11 +60,9 @@ class HomePage extends Component<any, IHomePageState> {
});
this.streamListener.on('update', (status: Status) => {
let posts = this.state.posts;
if (posts) {
posts.unshift(status)
}
this.setState({ posts });
let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) { queue.push(status); } else { queue = [status] }
this.setState({ backlogPosts: queue });
})
this.streamListener.on('delete', (id: number) => {
@ -91,6 +92,16 @@ class HomePage extends Component<any, IHomePageState> {
})
}
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null })
}
}
loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true})
if (this.state.posts) {
@ -123,6 +134,27 @@ class HomePage extends Component<any, IHomePageState> {
return (
<div className={classes.pageLayoutMaxConstraints}>
{
this.state.backlogPosts?
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon/>
</Avatar>
}
label={`View ${this.state.backlogPosts.length} new posts`}
color="primary"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
</Slide>
</div>
</div>: null
}
{ this.state.posts?
<div>
{this.state.posts.map((post: Status) => {

189
src/pages/Local.tsx Normal file
View File

@ -0,0 +1,189 @@
import React, { Component } from 'react';
import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide} from '@material-ui/core';
import {styles} from './PageLayout.styles';
import Post from '../components/Post';
import { Status } from '../types/Status';
import Mastodon from 'megalodon';
import {withSnackbar} from 'notistack';
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
interface ILocalPageState {
posts?: [Status];
backlogPosts?: [Status] | null;
viewIsLoading: boolean;
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: any;
}
class LocalPage extends Component<any, ILocalPageState> {
client: Mastodon;
streamListener: any;
constructor(props: any) {
super(props);
this.state = {
viewIsLoading: true,
backlogPosts: null
}
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
}
componentWillMount() {
this.streamListener = this.client.stream('/streaming/local');
this.streamListener.on('connect', () => {
this.client.get('/timelines/public', {limit: 40, local: true}).then((resp: any) => {
let statuses: [Status] = resp.data;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
})
}).catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
})
this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error',
});
})
});
this.streamListener.on('update', (status: Status) => {
let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) { queue.push(status); } else { queue = [status] }
this.setState({ backlogPosts: queue });
})
this.streamListener.on('delete', (id: number) => {
let posts = this.state.posts;
if (posts) {
posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1);
}
})
this.setState({ posts });
}
})
this.streamListener.on('error', (err: Error) => {
this.setState({
viewDidError: true,
viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("An error occured.", {
variant: 'error',
});
})
this.streamListener.on('heartbeat', () => {
})
}
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null })
}
}
loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true})
if (this.state.posts) {
this.client.get('/timelines/home', { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 20 }).then((resp: any) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
})
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
});
})
}
}
render() {
const {classes} = this.props;
return (
<div className={classes.pageLayoutMaxConstraints}>
{
this.state.backlogPosts?
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon/>
</Avatar>
}
label={`View ${this.state.backlogPosts.length} new posts`}
color="primary"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
</Slide>
</div>
</div>: null
}
{ this.state.posts?
<div>
{this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/>
})}
<br/>
{
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null
}
</div>:
<span/>
}
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(LocalPage));

View File

@ -30,6 +30,13 @@ export const styles = (theme: Theme) => createStyles({
paddingRight: theme.spacing.unit * 16,
},
[theme.breakpoints.up('lg')]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
paddingLeft: theme.spacing.unit * 32,
paddingRight: theme.spacing.unit * 32,
},
[theme.breakpoints.up('xl')]: {
marginLeft: 250,
marginTop: 88,
padding: theme.spacing.unit * 3,
@ -119,5 +126,19 @@ export const styles = (theme: Theme) => createStyles({
errorCard: {
padding: theme.spacing.unit * 4,
backgroundColor: theme.palette.error.main,
},
pageTopChipContainer: {
zIndex: 24,
position: "fixed",
width: '100%'
},
pageTopChips: {
textAlign: 'center',
[theme.breakpoints.up('md')]: {
marginRight: '55%'
}
},
pageTopChip: {
boxShadow: theme.shadows[10]
}
});

View File

@ -3,12 +3,15 @@ import {withStyles, Typography, Avatar, Divider, Button, CircularProgress, Paper
import {styles} from './PageLayout.styles';
import Mastodon from 'megalodon';
import { Account } from '../types/Account';
import { Status} from '../types/Status';
import { Status } from '../types/Status';
import { Relationship } from '../types/Relationship';
import Post from '../components/Post';
import {withSnackbar} from 'notistack';
import { LinkableButton } from '../interfaces/overrides';
interface IProfilePageState {
account?: Account;
relationship?: Relationship;
posts?: [Status];
viewIsLoading: boolean;
viewDidLoad?: boolean;
@ -31,8 +34,8 @@ class ProfilePage extends Component<any, IProfilePageState> {
}
}
componentWillReceiveProps(props: any) {
this.client.get(`/accounts/${props.match.params.profileId}`).then((resp: any) => {
getAccountData(id: string) {
this.client.get(`/accounts/${id}`).then((resp: any) => {
let profile: Account = resp.data;
const div = document.createElement('div');
@ -48,9 +51,8 @@ class ProfilePage extends Component<any, IProfilePageState> {
viewDidError: true,
viewDidErrorCode: error.message
})
})
this.client.get(`/accounts/${props.match.params.profileId}/statuses`).then((resp: any) => {
});
this.client.get(`/accounts/${id}/statuses`).then((resp: any) => {
this.setState({
posts: resp.data,
viewIsLoading: false,
@ -63,44 +65,30 @@ class ProfilePage extends Component<any, IProfilePageState> {
viewDidError: true,
viewDidErrorCode: err.message
})
})
window.scrollTo(0, 0)
});
}
componentWillReceiveProps(props: any) {
this.getAccountData(props.match.params.profileId);
window.scrollTo(0, 0);
}
componentWillMount() {
const { match: { params }} = this.props;
this.client.get(`/accounts/${params.profileId}`).then((resp: any) => {
let profile: Account = resp.data;
this.getAccountData(params.profileId);
}
const div = document.createElement('div');
div.innerHTML = profile.note;
profile.note = div.textContent || div.innerText || "";
this.setState({
account: profile
})
componentDidMount() {
this.client.get("/accounts/relationships", {id: [this.props.match.params.profileId]}).then((resp: any) => {
let relationship: Relationship = resp.data;
this.setState({ relationship });
}).catch((error: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: error.message
})
})
this.client.get(`/accounts/${params.profileId}/statuses`).then((resp: any) => {
this.setState({
posts: resp.data,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
})
}).catch( (err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
})
})
});
}
statElement(classes: any, stat: 'following' | 'followers' | 'posts') {
@ -156,7 +144,6 @@ class ProfilePage extends Component<any, IProfilePageState> {
render() {
const { classes } = this.props;
return(
<div className={classes.pageLayoutMinimalConstraints}>
<div className={classes.pageHeroBackground}>
@ -173,9 +160,14 @@ class ProfilePage extends Component<any, IProfilePageState> {
{this.statElement(classes, 'posts')}
</div>
<Divider/>
<Button variant="contained" color="primary" className={classes.pageProfileFollowButton}>Follow</Button>
<Button variant="contained" className={classes.pageProfileFollowButton}>Mention</Button>
<Button variant="contained" className={classes.pageProfileFollowButton}>Block</Button>
{
this.state.account && this.state.relationship?
<div>
<Button variant="contained" color="primary" className={classes.pageProfileFollowButton}>{this.state.relationship? "Unfollow": "Follow"}</Button>
<LinkableButton to={`/compose?mention=${this.state.account.acct}`} variant="contained" className={classes.pageProfileFollowButton}>Mention</LinkableButton>
<Button variant="contained" className={classes.pageProfileFollowButton}>{this.state.relationship.blocking? "Unblock": "Block"}</Button>
</div>: null
}
</div>
</div>
<div className={classes.pageContentLayoutConstraints}>

189
src/pages/Public.tsx Normal file
View File

@ -0,0 +1,189 @@
import React, { Component } from 'react';
import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide} from '@material-ui/core';
import {styles} from './PageLayout.styles';
import Post from '../components/Post';
import { Status } from '../types/Status';
import Mastodon from 'megalodon';
import {withSnackbar} from 'notistack';
import ArrowUpwardIcon from '@material-ui/icons/ArrowUpward';
interface IPublicPageState {
posts?: [Status];
backlogPosts?: [Status] | null;
viewIsLoading: boolean;
viewDidLoad?: boolean;
viewDidError?: boolean;
viewDidErrorCode?: any;
}
class PublicPage extends Component<any, IPublicPageState> {
client: Mastodon;
streamListener: any;
constructor(props: any) {
super(props);
this.state = {
viewIsLoading: true,
backlogPosts: null
}
this.client = new Mastodon(localStorage.getItem('access_token') as string, localStorage.getItem('baseurl') as string + "/api/v1");
}
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;
this.setState({
posts: statuses,
viewIsLoading: false,
viewDidLoad: true,
viewDidError: false
})
}).catch((resp: any) => {
this.setState({
viewIsLoading: false,
viewDidLoad: true,
viewDidError: true,
viewDidErrorCode: String(resp)
})
this.props.enqueueSnackbar("Failed to get posts.", {
variant: 'error',
});
})
});
this.streamListener.on('update', (status: Status) => {
let queue = this.state.backlogPosts;
if (queue !== null && queue !== undefined) { queue.push(status); } else { queue = [status] }
this.setState({ backlogPosts: queue });
})
this.streamListener.on('delete', (id: number) => {
let posts = this.state.posts;
if (posts) {
posts.forEach((post: Status) => {
if (posts && parseInt(post.id) === id) {
posts.splice(posts.indexOf(post), 1);
}
})
this.setState({ posts });
}
})
this.streamListener.on('error', (err: Error) => {
this.setState({
viewDidError: true,
viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("An error occured.", {
variant: 'error',
});
})
this.streamListener.on('heartbeat', () => {
})
}
insertBacklog() {
window.scrollTo(0, 0);
let posts = this.state.posts;
let backlog = this.state.backlogPosts;
if (posts && backlog && backlog.length > 0) {
let push = backlog.concat(posts);
this.setState({ posts: push as [Status], backlogPosts: null })
}
}
loadMoreTimelinePieces() {
this.setState({ viewDidLoad: false, viewIsLoading: true})
if (this.state.posts) {
this.client.get('/timelines/home', { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 20 }).then((resp: any) => {
let newPosts: [Status] = resp.data;
let posts = this.state.posts as [Status];
newPosts.forEach((post: Status) => {
posts.push(post);
});
this.setState({
viewIsLoading: false,
viewDidLoad: true,
posts
})
}).catch((err: Error) => {
this.setState({
viewIsLoading: false,
viewDidError: true,
viewDidErrorCode: err.message
})
this.props.enqueueSnackbar("Failed to get posts", {
variant: 'error',
});
})
}
}
render() {
const {classes} = this.props;
return (
<div className={classes.pageLayoutMaxConstraints}>
{
this.state.backlogPosts?
<div className={classes.pageTopChipContainer}>
<div className={classes.pageTopChips}>
<Slide direction="down" in={true}>
<Chip
avatar={
<Avatar>
<ArrowUpwardIcon/>
</Avatar>
}
label={`View ${this.state.backlogPosts.length} new posts`}
color="primary"
className={classes.pageTopChip}
onClick={() => this.insertBacklog()}
clickable
/>
</Slide>
</div>
</div>: null
}
{ this.state.posts?
<div>
{this.state.posts.map((post: Status) => {
return <Post key={post.id} post={post} client={this.client}/>
})}
<br/>
{
this.state.viewDidLoad && !this.state.viewDidError? <div style={{textAlign: "center"}} onClick={() => this.loadMoreTimelinePieces()}><Button variant="contained">Load more</Button></div>: null
}
</div>:
<span/>
}
{
this.state.viewDidError?
<Paper className={classes.errorCard}>
<Typography variant="h4">Bummer.</Typography>
<Typography variant="h6">Something went wrong when loading this timeline.</Typography>
<Typography>{this.state.viewDidErrorCode? this.state.viewDidErrorCode: ""}</Typography>
</Paper>:
<span/>
}
{
this.state.viewIsLoading?
<div style={{ textAlign: 'center' }}><CircularProgress className={classes.progress} color="primary" /></div>:
<span/>
}
</div>
);
}
}
export default withStyles(styles)(withSnackbar(PublicPage));

View File

@ -0,0 +1,12 @@
export type Relationship = {
id: string;
following: boolean;
followed_by: boolean;
blocking: boolean;
muting: boolean;
muting_notifications: boolean;
requested: boolean;
domain_blocking: boolean;
showing_reblogs: boolean;
endorsed: boolean;
}

View File

@ -5,6 +5,7 @@ import { Attachment } from './Attachment';
import { Mention } from './Mention';
import { Poll } from './Poll';
import { Card } from './Card';
import { Tag } from './Tag';
/**
* Basic type for a status on Mastodon
@ -31,7 +32,7 @@ export type Status = {
visibility: Visibility;
media_attachments: [Attachment];
mentions: [Mention];
tags: any;
tags: [Tag];
card: Card | null;
poll: Poll | null;
application: any;

4
src/types/Tag.tsx Normal file
View File

@ -0,0 +1,4 @@
export type Tag = {
name: string;
url: string;
}