import React, { Component } from "react"; import { withStyles, CircularProgress, Typography, Paper, Button, Chip, Avatar, Slide, StyledComponentProps } from "@material-ui/core"; import { styles } from "./PageLayout.styles"; import Post from "../components/Post"; import { Status } from "../types/Status"; import Mastodon, { StreamListener } from "megalodon"; import { withSnackbar, withSnackbarProps } from "notistack"; import Masonry from "react-masonry-css"; import { getUserDefaultBool } from "../utilities/settings"; import ArrowUpwardIcon from "@material-ui/icons/ArrowUpward"; /** * The basic interface for a timeline page's properties. */ interface ITimelinePageProps extends withSnackbarProps, StyledComponentProps { /** * The API endpoint for the timeline to fetch after starting * a stream. */ timeline: string; /** * The API endpoint for the timeline to stream. */ stream: string; classes?: any; } /** * The base interface for the timeline page's state. */ interface ITimelinePageState { /** * The list of posts from the timeline. */ posts?: [Status]; /** * The list of posts stored temporarily while viewing the timeline. * * Can be cleared when user pushes "Show x posts" button. */ backlogPosts?: [Status] | null; /** * Whether the view is currently loading. */ viewIsLoading: boolean; /** * Whether the view loaded successfully. */ viewDidLoad?: boolean; /** * Whether the view errored. */ viewDidError?: boolean; /** * The view's error code, if it errored. */ viewDidErrorCode?: any; /** * Whether or not to use the masonry layout as defined in * the user settings. */ isMasonryLayout?: boolean; /** * Whether posts should automatically load when scrolling. */ isInfiniteScroll?: boolean; } /** * The base class for a timeline page. * * The timeline page streams a specific timeline. When the stream is connected, * the page will fetch a particular timeline list of posts. The timeline page will * also off-load incoming posts from the stream into a backlog that the user can * then insert by clicking a button. */ class TimelinePage extends Component { /** * The client to use. */ client: Mastodon; /** * The page's stream listener. */ streamListener: StreamListener; /** * Construct the timeline page. * @param props The timeline page's properties */ constructor(props: ITimelinePageProps) { super(props); // Initialize the state. this.state = { viewIsLoading: true, backlogPosts: null, isMasonryLayout: getUserDefaultBool("isMasonryLayout"), isInfiniteScroll: getUserDefaultBool("isInfiniteScroll"), }; // Generate the client. this.client = new Mastodon( localStorage.getItem("access_token") as string, (localStorage.getItem("baseurl") as string) + "/api/v1" ); // Create the stream listener from the properties. this.streamListener = this.client.stream(this.props.stream); this.loadMoreTimelinePieces = this.loadMoreTimelinePieces.bind(this); this.shouldLoadMorePosts = this.shouldLoadMorePosts.bind(this); } /** * Connect the stream listener and listen for new posts. */ componentWillMount() { this.streamListener.on("connect", () => { // Get the latest posts from this timeline. this.client .get(this.props.timeline, { limit: 50 }) // If we succeeded, update the state and turn off loading. .then((resp: any) => { let statuses: [Status] = resp.data; this.setState({ posts: statuses, viewIsLoading: false, viewDidLoad: true, viewDidError: false }); }) // Otherwise, update the state in error. .catch((resp: any) => { this.setState({ viewIsLoading: false, viewDidLoad: true, viewDidError: true, viewDidErrorCode: String(resp) }); // Notify the user with a snackbar. this.props.enqueueSnackbar("Failed to get posts.", { variant: "error" }); }); }); // Store incoming posts into a backlog if possible. this.streamListener.on("update", (status: Status) => { let queue = this.state.backlogPosts; if (queue !== null && queue !== undefined) { queue.unshift(status); } else { queue = [status]; } this.setState({ backlogPosts: queue }); }); // When a post is deleted in the backend, find the post in the list // and remove it from the list. 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 }); } }); // Display an error if the stream encounters and error. 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", () => {}); } /** * Insert a delay between repeated function calls * codeburst.io/throttling-and-debouncing-in-javascript-646d076d0a44 * @param delay How long to wait before calling function (ms) * @param fn The function to call */ debounced(delay: number, fn: Function) { let lastCall = 0 return function(...args: any) { const now = (new Date).getTime(); if (now - lastCall < delay) { return } lastCall = now; return fn(...args) } } /** * Listen for when scroll position changes */ componentDidMount() { if (this.state.isInfiniteScroll) { window.addEventListener( "scroll", this.debounced(200, this.shouldLoadMorePosts), ); } } /** * Halt the stream and scroll listeners when unmounting the component. */ componentWillUnmount() { this.streamListener.stop(); if (this.state.isInfiniteScroll) { window.removeEventListener( "scroll", this.shouldLoadMorePosts, ); } } /** * Insert the posts from the backlog into the current list of posts * and clear the backlog. */ 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 }); } } /** * Load the next set of posts, if it exists. */ loadMoreTimelinePieces() { // Reinstate the loading status. this.setState({ viewDidLoad: false, viewIsLoading: true }); // If there are any posts, get the next set. if (this.state.posts) { this.client .get(this.props.timeline, { max_id: this.state.posts[this.state.posts.length - 1].id, limit: 50 }) // If we succeeded, append them to the end of the list of posts. .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 }); }) // If we errored, display the error and don't do anything. .catch((err: Error) => { this.setState({ viewIsLoading: false, viewDidError: true, viewDidErrorCode: err.message }); this.props.enqueueSnackbar("Failed to get posts", { variant: "error" }); }); } } /** * Load more posts when scroll is near the end of the page */ shouldLoadMorePosts(e: Event) { let difference = document.body.clientHeight - window.scrollY - window.innerHeight; if (difference < 10000 && this.state.viewIsLoading === false) { this.loadMoreTimelinePieces(); } } /** * Render the timeline page. */ render() { const { classes } = this.props; const containerClasses = `${classes.pageLayoutMaxConstraints}${ this.state.isMasonryLayout ? " " + classes.pageLayoutMasonry : "" }`; return (
{this.state.backlogPosts ? (
} label={`View ${ this.state.backlogPosts.length } new post${ this.state.backlogPosts.length > 1 ? "s" : "" }`} color="primary" className={classes.pageTopChip} onClick={() => this.insertBacklog()} clickable />
) : null} {this.state.posts ? (
{this.state.isMasonryLayout ? ( {this.state.posts.map((post: Status) => { return (
); })}
) : (
{this.state.posts.map((post: Status) => { return ( ); })}
)}
{this.state.viewDidLoad && !this.state.viewDidError ? (
this.loadMoreTimelinePieces()} >
) : null}
) : ( )} {this.state.viewDidError ? ( Bummer. Something went wrong when loading this timeline. {this.state.viewDidErrorCode ? this.state.viewDidErrorCode : ""} ) : ( )} {this.state.viewIsLoading ? (
) : ( )}
); } } export default withStyles(styles)(withSnackbar(TimelinePage));