diff --git a/src/api.tsx b/src/api.tsx new file mode 100644 index 0000000..a3062e0 --- /dev/null +++ b/src/api.tsx @@ -0,0 +1,63 @@ +export enum SearchType { + Comments, + Posts, +} + +export interface SearchSettings { + author: string, + subreddit: string, + searchFor: SearchType, + resultSize: number, + filter: string, + after: Date, + before: Date, + query: string, +} + +export class PushshiftAPI { + get_url(settings: SearchSettings): string { + let args = { + html_decode: "true", + }; + if (settings.after) { + args["after"] = (settings.after.valueOf() / 1000).toString(); + } + if (settings.before) { + args["before"] = (settings.before.valueOf() / 1000).toString(); + } + if (settings.author) { + args["author"] = settings.author; + } + if (settings.subreddit) { + args["subreddit"] = settings.subreddit; + } + if (settings.query) { + args["q"] = settings.query; + } + if (settings.resultSize) { + args["size"] = settings.resultSize.toString(); + } + if (settings.filter) { + args["score"] = settings.filter; + } + let joinedArgs = Object.entries(args).map(([k, v]) => `${k}=${v}`).join('&'); + let endpoint; + if (settings.searchFor == SearchType.Comments) { + endpoint = "comment"; + } + else if (settings.searchFor == SearchType.Posts) { + endpoint = "submission"; + } + return `https://api.pushshift.io/reddit/${endpoint}/search?${joinedArgs}` + } + + async query(url: string): Promise { + + console.log(`Pushshift request ${url}`); + let resp = await fetch(url, { + referrerPolicy: "no-referrer" + }); + let data = await resp.json(); + return data; + } +} diff --git a/src/app.tsx b/src/app.tsx new file mode 100644 index 0000000..c5d67e9 --- /dev/null +++ b/src/app.tsx @@ -0,0 +1,425 @@ +import * as React from 'react'; +import DatePicker from 'react-datepicker' +import { PushshiftAPI, SearchSettings, SearchType } from './api' +import { GithubLink } from './github-link' + +import "react-datepicker/dist/react-datepicker.css"; +import { RandomLink } from './random-link'; + +interface AppState extends SearchSettings { + error: string, + errorTime: Date, + searching: boolean, + comments: Array, + posts: Array, + moreing: boolean, + lastUrl: string, +} + +/** Main class for Reddit Search */ +export class App extends React.Component<{}, AppState> { + lastSearch: SearchSettings; + api: PushshiftAPI; + updatedHash: boolean; + + constructor(props) { + super(props); + this.state = { + author: "", + subreddit: "", + searchFor: SearchType.Comments, + resultSize: 100, + filter: "", + after: null, + before: null, + query: "", + error: null, + errorTime: null, + searching: false, + comments: null, + posts: null, + moreing: false, + lastUrl: "", + }; + this.api = new PushshiftAPI(); + this.updatedHash = false; + } + + loadLocationHash(shouldSearch: boolean = false) { + let params = hash_accessor.load(); + if (params.after) { + params.after = new Date(params.after); + } + if (params.before) { + params.before = new Date(params.before); + } + if (shouldSearch) { + this.setState(params, this.doSearch); + } else { + this.setState(params); + } + } + + componentDidMount() { + // Add hash change event listener + window.addEventListener("hashchange", e => { + if (this.updatedHash) { + this.updatedHash = false; + return; + } + console.log("location.hash changed. loading new params"); + this.loadLocationHash(); + }); + + // Check for location hash. Use it if found + if (window.location.hash) { + this.loadLocationHash(true); + console.log("Loaded params from location.hash"); + return; + } + + // Load stored form data if exists + let formDataJson = localStorage.getItem("search-form"); + if (formDataJson !== null) { + let formData: SearchSettings = JSON.parse(formDataJson); + // Reconstruct `Date`s + if (formData.after) { + formData.after = new Date(formData.after); + } + if (formData.before) { + formData.before = new Date(formData.before); + } + this.setState(formData); + console.log("Loaded params from local storage"); + } + } + + componentDidUpdate() { + let toSave: SearchSettings = { + author: this.state.author, + subreddit: this.state.subreddit, + searchFor: this.state.searchFor, + resultSize: this.state.resultSize, + filter: this.state.filter, + after: this.state.after, + before: this.state.before, + query: this.state.query, + }; + localStorage.setItem("search-form", JSON.stringify(toSave)); + } + + setError = (error: string) => { + this.setState({ error: error, errorTime: new Date() }); + } + + handleAuthorChange = (e: React.ChangeEvent) => { + this.setState({ author: e.target.value }); + } + + handleSubredditChange = (e: React.ChangeEvent) => { + this.setState({ subreddit: e.target.value }); + } + + handleSearchTypeChange = (e: React.ChangeEvent) => { + if (e.target.value === "Comments") { + this.setState({ searchFor: SearchType.Comments }); + } else if (e.target.value === "Posts") { + this.setState({ searchFor: SearchType.Posts }); + } else { + this.setError(e.target.value + " is not a valid search type"); + } + } + + handleResultSizeChange = (e: React.ChangeEvent) => { + let count: number = parseInt(e.target.value); + if (!count) { + return; + } + this.setState({ resultSize: count }); + } + + handleFilterChange = (e: React.ChangeEvent) => { + this.setState({ filter: e.target.value }); + } + + handleAfterDateChange = (date: Date) => { + this.setState({ after: date }); + } + + handleBeforeDateChange = (date: Date) => { + this.setState({ before: date }); + } + + handleQueryChange = (e: React.ChangeEvent) => { + this.setState({ query: e.target.value }); + } + + doSearch = async () => { + this.setState({ error: null, comments: null, posts: null, searching: true }); + this.lastSearch = { ...this.state }; + + // Update location.hash + let toSave = { + author: this.state.author, + subreddit: this.state.subreddit, + searchFor: this.state.searchFor, + resultSize: this.state.resultSize, + filter: this.state.filter, + after: this.state.after, + before: this.state.before, + query: this.state.query, + }; + this.updatedHash = true; + hash_accessor.save(toSave); + + // Search + try { + let url = this.api.get_url(this.lastSearch); + this.setState({ lastUrl: url }); + let data = await this.api.query(url); + // Update state with results + if (this.lastSearch.searchFor == SearchType.Comments) { + this.setState({ comments: data.data, searching: false }); + } else if (this.lastSearch.searchFor == SearchType.Posts) { + this.setState({ posts: data.data, searching: false }); + } + } catch (err) { + this.setState({ searching: false }); + this.setError(String(err)); + } + } + + /** Handle the main form being submitted */ + searchSubmit = async (e) => { + // Update state + e.preventDefault(); + this.doSearch(); + } + + /** Handle the more button being clicked. */ + handleMoreClick = async (e) => { + this.setState({ error: null, moreing: true }); + if (this.state.comments && this.state.comments.length > 0) { + this.lastSearch.before = new Date(this.state.comments[this.state.comments.length - 1].created_utc * 1000); + } else if (this.state.posts && this.state.posts.length > 0) { + this.lastSearch.before = new Date(this.state.posts[this.state.posts.length - 1].created_utc * 1000); + } + let url = this.api.get_url(this.lastSearch); + let data = await this.api.query(url); + if (this.lastSearch.searchFor == SearchType.Comments && data.data) { + this.setState({ comments: this.state.comments.concat(data.data), moreing: false }); + } else if (this.lastSearch.searchFor == SearchType.Posts && data.data) { + this.setState({ posts: this.state.posts.concat(data.data), moreing: false }); + } else { + this.setState({ moreing: false }); + } + } + + /** Render the app + * @return {React.ReactNode} The react node for the app + */ + render(): React.ReactNode { + // Not tidy at all but it's a one page app so WONTFIX + let moreButton = ; + let linkClass = "text-blue-400 hover:text-blue-600"; + let content; + let resultCount; + let inner; + if (this.state.comments) { + resultCount = this.state.comments.length; + // Render comments + inner = this.state.comments.map((comment) => { + if (!comment) { + return; + } + let permalink; + if (comment.permalink) { + permalink = comment.permalink; + } else { + permalink = `/comments/${comment.link_id.split('_')[1]}/_/${comment.id}/` + } + return
+
+ +
/r/{comment.subreddit}
+
+ +
/u/{comment.author}
+
+
{new Date(comment.created_utc * 1000).toLocaleString()}
+
+ +
{comment.body}
+
+
+ }); + } else if (this.state.posts && this.state.posts.length > 0) { + resultCount = this.state.posts.length; + // Render posts + inner = this.state.posts.map((post) => { + if (!post) { + return; + } + let thumbnailUrl; + if (post.thumbnail.startsWith('http')) { + thumbnailUrl = post.thumbnail; + } else if (post.url.split('.').pop() === 'png' || post.url.split('.').pop() === 'jpg') { + thumbnailUrl = post.url; + } + let body; + if (post.selftext && post.selftext.length !== 0) { + body =
{post.selftext}
+ } else { + body = +
{post.url}
+
+ } + return
+
+ +
/r/{post.subreddit}
+
+ +
/u/{post.author}
+
+
{new Date(post.created_utc * 1000).toLocaleString()}
+
+
+
+ +
+
+ +
{post.title}
+
+ {body} +
+
+
+ }); + } + if (this.state.comments || this.state.posts) { + content =
+
{resultCount} results - Generated API URL
+ {inner} + {moreButton} +
+ } else if (this.state.lastUrl) { + content = + } else { + content =
+

Search reddit using the pushshift.io API. For more advanced searches you can directly query the API fairly easily.

+

The 'More' button works by changing the 'before' value to the time of the last post in the results. This means that entries might be missed if they were posted at the same time.

+
+ } + // Combine everything and return + return ( + <> + +
+ {/* Author and Subreddit */} +
+
+ + +
+
+ + +
+
+ {/* Type, Count and Score Filter */} +
+
+ +
+ +
+ +
+
+
+
+ + { }} /> +
+
+ + +
+
+ {/* Time Range */} +
+
+ + +
+
+ + +
+
+ {/* Search Term */} +
+ + +
+ {/* Submit Button and Error text */} + + {this.state.error && + <> +

{this.state.errorTime.toLocaleTimeString()} Error: {this.state.error}

+ + } +
+ {content} +
+ + ); + } +} + +// https://gist.github.com/jokester/4a543ea76dbc5ae1bf05 +var hash_accessor = (function (window) { + return { + load: function () { + try { + // strip ^# + var json_str_escaped = window.location.hash.slice(1); + // unescape + var json_str = decodeURIComponent(json_str_escaped); + return JSON.parse(json_str); + } catch (e) { + return {}; + } + }, + save: function (obj) { + // use replace so that previous url does not go into history + window.location.replace('#' + JSON.stringify(obj, (key, value) => { if (value) return value; })); + } + }; +})(window); diff --git a/src/error.tsx b/src/error.tsx new file mode 100644 index 0000000..ee24835 --- /dev/null +++ b/src/error.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +export interface ErrorState { + errorOccurred: boolean, + errorInfo: object, + error: object, +} + +export class ErrorWrapper extends React.Component<{}, ErrorState> { + constructor(props) { + super(props) + this.state = { errorOccurred: false, errorInfo: null, error: null } + } + + componentDidCatch(error, info) { + this.setState({ errorOccurred: true, errorInfo: info, error: error }) + } + + render() { + if (this.state.errorOccurred) { + console.log(this.state.error); + console.log(this.state.errorInfo); + return <> +
+

An error occured! code isn't exactly "enterprise" so feel free to tell me on Github or use pushshift directly

+
+

{this.state.error.message}

+

{this.state.errorInfo.componentStack.trim()}

+
+
+ ; + } else { + return this.props.children; + } + } +} diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000..9022d4c Binary files /dev/null and b/src/favicon.ico differ diff --git a/src/github-link.tsx b/src/github-link.tsx new file mode 100644 index 0000000..cd30073 --- /dev/null +++ b/src/github-link.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export class GithubLink extends React.Component { + render(): React.ReactNode { + // http://tholman.com/github-corners/ + // https://magic.reactjs.net/htmltojsx.htm + return
+