hyperspace-desktop-client-w.../src/components/Post/Post.tsx

771 lines
23 KiB
TypeScript

import React from "react";
import {
Typography,
IconButton,
Card,
CardHeader,
Avatar,
CardContent,
CardActions,
withStyles,
Menu,
MenuItem,
Chip,
Divider,
CardMedia,
CardActionArea,
ExpansionPanel,
ExpansionPanelSummary,
ExpansionPanelDetails,
Zoom,
Tooltip,
RadioGroup,
Radio,
FormControlLabel,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions
} 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";
import AutorenewIcon from "@material-ui/icons/Autorenew";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
import PublicIcon from "@material-ui/icons/Public";
import VisibilityOffIcon from "@material-ui/icons/VisibilityOff";
import WarningIcon from "@material-ui/icons/Warning";
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";
import AttachmentComponent from "../Attachment";
import Mastodon from "megalodon";
import {
LinkableChip,
LinkableMenuItem,
LinkableIconButton,
LinkableAvatar
} from "../../interfaces/overrides";
import { withSnackbar } from "notistack";
import ShareMenu from "./PostShareMenu";
import { emojifyString } from "../../utilities/emojis";
import { PollOption } from "../../types/Poll";
interface IPostProps {
post: Status;
classes: any;
client: Mastodon;
}
interface IPostState {
post: Status;
media_slides?: number;
menuIsOpen: boolean;
myVote?: [number];
deletePostDialog: boolean;
}
export class Post extends React.Component<any, IPostState> {
client: Mastodon;
constructor(props: any) {
super(props);
this.state = {
post: this.props.post,
media_slides:
this.props.post.media_attachments.length > 0
? this.props.post.media_attachments.length
: 0,
menuIsOpen: false,
deletePostDialog: false
};
this.client = this.props.client;
}
togglePostMenu() {
this.setState({ menuIsOpen: !this.state.menuIsOpen });
}
togglePostDeleteDialog() {
this.setState({ deletePostDialog: !this.state.deletePostDialog });
}
deletePost() {
this.client
.del("/statuses/" + this.state.post.id)
.then((resp: any) => {
this.props.enqueueSnackbar("Post deleted. Refresh to see changes.");
})
.catch((err: Error) => {
this.props.enqueueSnackbar("Couldn't delete post: " + err.name);
console.log(err.message);
});
}
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;
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");
}
});
oldContent.innerHTML = emojifyString(
oldContent.innerHTML,
status.emojis,
classes.postEmoji
);
return (
<CardContent className={classes.postContent}>
<div className={classes.mediaContainer}>
<Typography
paragraph
dangerouslySetInnerHTML={{ __html: oldContent.innerHTML }}
/>
{status.card ? (
<div className={classes.postCard}>
<Divider />
<CardActionArea
href={status.card.url}
target="_blank"
rel="noreferrer"
>
<CardContent>
<Typography gutterBottom variant="h6" component="h2">
{status.card.title}
</Typography>
<Typography>
{status.card.description.slice(0, 500) +
(status.card.description.length > 500 ? "..." : "") ||
"No description provided. Click with caution."}
</Typography>
</CardContent>
{status.card.image && status.media_attachments.length <= 0 ? (
<CardMedia
className={classes.postMedia}
image={status.card.image}
/>
) : (
<span />
)}
<CardContent>
<Typography>
{status.card.provider_url ||
status.card.author_url ||
status.card.author_url}
</Typography>
</CardContent>
</CardActionArea>
<Divider />
</div>
) : (
<span />
)}
{status.media_attachments.length > 0 ? (
<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 (
this.spoilerContainsFlags(spoiler_text) ||
spoiler_text.includes("Spoiler") ||
warningText === "Unmarked content"
) {
icon = <WarningIcon className={classes.postWarningIcon} />;
}
return (
<ExpansionPanel
className={
this.spoilerContainsFlags(spoiler_text)
? classes.nsfwCard
: classes.spoilerCard
}
color="inherit"
>
<ExpansionPanelSummary expandIcon={<ExpandMoreIcon />} color="inherit">
{icon}
<Typography>{warningText}</Typography>
</ExpansionPanelSummary>
<ExpansionPanelDetails className={classes.postContent} color="inherit">
{this.materializeContent(content)}
</ExpansionPanelDetails>
</ExpansionPanel>
);
}
getReblogOfPost(of: Status | null) {
const { classes } = this.props;
if (of !== null) {
return of.sensitive
? this.getSensitiveContent(of.spoiler_text, of)
: this.materializeContent(of);
} else {
return null;
}
}
getReblogAuthors(post: Status) {
const { classes } = this.props;
if (post.reblog) {
let author = post.reblog.account;
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;
let origString = `<span>${author.display_name || author.username} (@${
author.acct
})</span>`;
return emojifyString(origString, author.emojis, classes.postAuthorEmoji);
}
}
getMentions(mention: [Mention]) {
const { classes } = this.props;
if (mention.length > 0) {
return (
<CardContent className={classes.postTags}>
<Typography variant="caption">Mentions</Typography>
{mention.map((person: Mention) => {
return (
<LinkableChip
avatar={
<Avatar>
<AlternateEmailIcon />
</Avatar>
}
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 className={classes.postTags}>
<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?query=${tag.name}&type=tag`}
className={classes.postMention}
clickable
/>
);
})}
</CardContent>
);
} else {
return null;
}
}
showVisibilityIcon(visibility: Visibility) {
const { classes } = this.props;
switch (visibility) {
case "public":
return (
<Tooltip title="Public">
<PublicIcon className={classes.postTypeIcon} />
</Tooltip>
);
case "private":
return (
<Tooltip title="Followers only">
<GroupIcon className={classes.postTypeIcon} />
</Tooltip>
);
case "unlisted":
return (
<Tooltip title="Unlisted (invisible from public timeline)">
<VisibilityOffIcon className={classes.postTypeIcon} />
</Tooltip>
);
}
}
getMastodonUrl(post: Status) {
let url = "";
if (post.reblog) {
url = post.reblog.uri;
} else {
url = post.uri;
}
return url;
}
toggleFavorited(post: Status) {
let _this = this;
if (post.favourited) {
this.client
.post(`/statuses/${post.id}/unfavourite`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
_this.props.enqueueSnackbar(`Couldn't unfavorite post: ${err.name}`, {
variant: "error"
});
console.log(err.message);
});
} else {
this.client
.post(`/statuses/${post.id}/favourite`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
_this.props.enqueueSnackbar(`Couldn't favorite post: ${err.name}`, {
variant: "error"
});
console.log(err.message);
});
}
}
toggleReblogged(post: Status) {
if (post.reblogged) {
this.client
.post(`/statuses/${post.id}/unreblog`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(`Couldn't unboost post: ${err.name}`, {
variant: "error"
});
console.log(err.message);
});
} else {
this.client
.post(`/statuses/${post.id}/reblog`)
.then((resp: any) => {
let post: Status = resp.data;
this.setState({ post });
})
.catch((err: Error) => {
this.props.enqueueSnackbar(`Couldn't boost post: ${err.name}`, {
variant: "error"
});
console.log(err.message);
});
}
}
showDeleteDialog() {
return (
<Dialog
open={this.state.deletePostDialog}
onClose={() => this.togglePostDeleteDialog()}
>
<DialogTitle id="alert-dialog-title">Delete this post?</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
Are you sure you want to delete this post? This action cannot be
undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button
onClick={() => this.togglePostDeleteDialog()}
color="primary"
autoFocus
>
Cancel
</Button>
<Button
onClick={() => {
this.deletePost();
this.togglePostDeleteDialog();
}}
color="primary"
>
Delete
</Button>
</DialogActions>
</Dialog>
);
}
render() {
const { classes } = this.props;
const post = this.state.post;
return (
<Zoom in={true}>
<Card className={classes.post} id={`post_${post.id}`}>
<CardHeader
avatar={
<LinkableAvatar
to={`/profile/${
post.reblog ? post.reblog.account.id : post.account.id
}`}
src={
post.reblog
? post.reblog.account.avatar_static
: post.account.avatar_static
}
/>
}
action={
<Tooltip title="More">
<IconButton
key={`${post.id}_submenu`}
id={`${post.id}_submenu`}
onClick={() => this.togglePostMenu()}
>
<MoreVertIcon />
</IconButton>
</Tooltip>
}
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}
{post.sensitive
? this.getSensitiveContent(post.spoiler_text, post)
: post.reblog
? null
: this.materializeContent(post)}
{post.reblog && post.reblog.mentions.length > 0
? this.getMentions(post.reblog.mentions)
: this.getMentions(post.mentions)}
{post.reblog && post.reblog.tags.length > 0
? this.getTags(post.reblog.tags)
: this.getTags(post.tags)}
<CardActions>
<Tooltip title="Reply">
<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>
<Typography>
{post.reblog ? post.reblog.replies_count : post.replies_count}
</Typography>
<Tooltip title="Favorite">
<IconButton onClick={() => this.toggleFavorited(post)}>
<FavoriteIcon
className={
post.reblog
? post.reblog.favourited
? classes.postDidAction
: ""
: post.favourited
? classes.postDidAction
: ""
}
/>
</IconButton>
</Tooltip>
<Typography>
{post.reblog
? post.reblog.favourites_count
: post.favourites_count}
</Typography>
<Tooltip title="Boost">
<IconButton onClick={() => this.toggleReblogged(post)}>
<AutorenewIcon
className={
post.reblog
? post.reblog.reblogged
? classes.postDidAction
: ""
: post.reblogged
? classes.postDidAction
: ""
}
/>
</IconButton>
</Tooltip>
<Typography>
{post.reblog ? post.reblog.reblogs_count : post.reblogs_count}
</Typography>
<Tooltip className={classes.desktopOnly} title="View thread">
<LinkableIconButton
to={`/conversation/${post.reblog ? post.reblog.id : post.id}`}
>
<ForumIcon />
</LinkableIconButton>
</Tooltip>
<Tooltip className={classes.desktopOnly} title="Open in Web">
<IconButton
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
<OpenInNewIcon />
</IconButton>
</Tooltip>
<div className={classes.postFlexGrow} />
<div className={classes.postTypeIconDiv}>
{this.showVisibilityIcon(post.visibility)}
</div>
</CardActions>
<Menu
id="postmenu"
anchorEl={document.getElementById(`${post.id}_submenu`)}
open={this.state.menuIsOpen}
onClose={() => this.togglePostMenu()}
>
<ShareMenu
config={{
params: {
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) => {
if (error.name != "AbortError")
this.props.enqueueSnackbar(
`Couldn't share post: ${error.name}`,
{ variant: "error" }
);
}
}}
/>
{post.reblog ? (
<div>
<LinkableMenuItem to={`/profile/${post.reblog.account.id}`}>
View author profile
</LinkableMenuItem>
<LinkableMenuItem to={`/profile/${post.account.id}`}>
View reblogger profile
</LinkableMenuItem>
</div>
) : (
<LinkableMenuItem to={`/profile/${post.account.id}`}>
View profile
</LinkableMenuItem>
)}
<div className={classes.mobileOnly}>
<Divider />
<LinkableMenuItem
to={`/conversation/${post.reblog ? post.reblog.id : post.id}`}
>
View thread
</LinkableMenuItem>
<MenuItem
component="a"
href={this.getMastodonUrl(post)}
rel="noreferrer"
target="_blank"
>
Open in Web
</MenuItem>
</div>
{post.account.id ==
JSON.parse(localStorage.getItem("account") as string).id ? (
<div>
<Divider />
<MenuItem onClick={() => this.togglePostDeleteDialog()}>
Delete
</MenuItem>
</div>
) : null}
{this.showDeleteDialog()}
</Menu>
</Card>
</Zoom>
);
}
}
export default withStyles(styles)(withSnackbar(Post));