add routing support for selected instance

This commit is contained in:
Tao Bojlén 2019-07-21 18:05:07 +00:00
parent 17488ff8a0
commit 39d279debd
33 changed files with 524 additions and 367 deletions

View File

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- It's now shown in the front-end if an instance wasn't crawled because of its robots.txt.
- You can now link directly to instances at e.g. /instance/mastodon.social.
- Instance details now have a link to the corresponding fediverse.network page.
### Changed
### Deprecated
### Removed

View File

@ -68,6 +68,6 @@ config :backend, :crawler,
config :backend, Backend.Scheduler,
jobs: [
# Every 15 minutes
{"*/15 * * * *", {Backend.Scheduler, :prune_crawls, [12, "hour"]}}
# Every 5 minutes
{"*/5 * * * *", {Backend.Scheduler, :prune_crawls, [12, "month"]}}
]

View File

@ -3,6 +3,7 @@
The React frontend for [fediverse.space](https://fediverse.space). Written in Typescript.
- Set the environment variable `REACT_APP_STAGING=true` when building to use the staging backend.
- React components are organized into atoms, molecules, organisms, and screens according to [Atomic Design](http://bradfrost.com/blog/post/atomic-web-design/).
# Default README

View File

@ -32,6 +32,7 @@
"@blueprintjs/icons": "^3.9.1",
"@blueprintjs/select": "^3.9.0",
"classnames": "^2.2.6",
"connected-react-router": "^6.5.2",
"cross-fetch": "^3.0.4",
"cytoscape": "^3.8.1",
"cytoscape-popper": "^1.0.4",

View File

@ -3,11 +3,12 @@ import * as React from "react";
import { Button, Classes, Dialog } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { BrowserRouter, Route } from "react-router-dom";
import { Nav } from "./components/Nav";
import { AboutScreen } from "./components/screens/AboutScreen";
import { GraphScreen } from "./components/screens/GraphScreen";
import { DESKTOP_WIDTH_THRESHOLD } from "./constants";
import { ConnectedRouter } from "connected-react-router";
import { Route, RouteComponentProps } from "react-router-dom";
import { Nav } from "./components/organisms/";
import { AboutScreen, GraphScreen } from "./components/screens/";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { history } from "./index";
interface IAppLocalState {
mobileDialogOpen: boolean;
@ -20,14 +21,19 @@ export class AppRouter extends React.Component<{}, IAppLocalState> {
public render() {
return (
<BrowserRouter>
<ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}>
<Nav />
<Route exact={true} path="/" component={GraphScreen} />
{/* We use `children={}` instead of `component={}` such that the graph is never unmounted */}
<Route exact={true} path="/">
<Route path={INSTANCE_DOMAIN_PATH}>
{(routeProps: RouteComponentProps<IInstanceDomainPath>) => <GraphScreen {...routeProps} />}
</Route>
</Route>
<Route path="/about" component={AboutScreen} />
{this.renderMobileDialog()}
</div>
</BrowserRouter>
</ConnectedRouter>
);
}

View File

@ -1,5 +0,0 @@
import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
export const ErrorState: React.SFC = () => <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} />;

View File

@ -1,101 +0,0 @@
import { get } from "lodash";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IGraph } from "../redux/types";
import Cytoscape from "./Cytoscape";
import { ErrorState } from "./ErrorState";
import { FloatingResetButton } from "./FloatingResetButton";
interface IGraphProps {
graph?: IGraph;
currentInstanceName: string | null;
selectAndLoadInstance: (name: string) => void;
}
class GraphImpl extends React.Component<IGraphProps> {
private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: IGraphProps) {
super(props);
this.cytoscapeComponent = React.createRef();
}
public render() {
if (!this.props.graph) {
return <ErrorState />;
}
return (
<div>
<Cytoscape
elements={this.props.graph}
onInstanceSelect={this.onInstanceSelect}
onInstanceDeselect={this.onInstanceDeselect}
ref={this.cytoscapeComponent}
/>
<FloatingResetButton onClick={this.resetGraphPosition} />
</div>
);
}
public componentDidUpdate(prevProps: IGraphProps) {
const { currentInstanceName } = this.props;
if (prevProps.currentInstanceName !== currentInstanceName) {
const cy = this.getCytoscape();
cy.$id(prevProps.currentInstanceName).unselect();
if (currentInstanceName) {
// Select instance
cy.$id(`${currentInstanceName}`).select();
// Center it
const selected = cy.$id(currentInstanceName);
cy.center(selected);
}
}
}
private resetGraphPosition = () => {
const cy = this.getCytoscape();
const { currentInstanceName } = this.props;
if (currentInstanceName) {
cy.zoom({
level: 0.2,
position: cy.$id(currentInstanceName).position()
});
} else {
cy.zoom({
level: 0.2,
position: { x: 0, y: 0 }
});
}
};
private onInstanceSelect = (domain: string) => {
this.props.selectAndLoadInstance(domain);
};
private onInstanceDeselect = () => {
this.props.selectAndLoadInstance("");
};
private getCytoscape = () => {
const cy = get(this.cytoscapeComponent, "current.cy");
if (!cy) {
throw new Error("Expected cytoscape component but did not find one.");
}
return cy;
};
}
const mapStateToProps = (state: IAppState) => ({
currentInstanceName: state.currentInstance.currentInstanceName,
graph: state.data.graph
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph;

View File

@ -1,41 +0,0 @@
import * as React from "react";
import { Alignment, Button, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { InstanceSearch } from "./InstanceSearch";
interface INavState {
aboutIsOpen: boolean;
}
export class Nav extends React.Component<{}, INavState> {
constructor(props: any) {
super(props);
this.state = { aboutIsOpen: false };
}
public render() {
const StyledLink = styled(Link)`
color: white !important;
`;
return (
<Navbar fixedToTop={true}>
<Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>fediverse.space</Navbar.Heading>
<Navbar.Divider />
<StyledLink to="/">
<Button icon={IconNames.GLOBE_NETWORK} text="Home" minimal={true} />
</StyledLink>
<StyledLink to="/about">
<Button icon={IconNames.INFO_SIGN} text="About" minimal={true} />
</StyledLink>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<InstanceSearch />
</Navbar.Group>
</Navbar>
);
}
}

View File

@ -1,7 +0,0 @@
import styled from "styled-components";
export const Page = styled.div`
max-width: 800px;
margin: auto;
padding: 2em;
`;

View File

@ -0,0 +1,26 @@
import * as React from "react";
import styled from "styled-components";
const Backdrop = styled.div`
position: absolute;
top: 50px;
bottom: 0;
left: 0;
right: 0;
background-color: #293742;
z-index: 100;
`;
const Container = styled.div`
max-width: 800px;
margin: auto;
padding: 2em;
`;
const Page: React.FC = ({ children }) => (
<Backdrop>
<Container>{children}</Container>
</Backdrop>
);
export default Page;

View File

@ -0,0 +1,2 @@
export { default as Page } from "./Page";
export { default as FloatingCard } from "./FloatingCard";

View File

@ -1,30 +1,32 @@
import cytoscape from "cytoscape";
import popper from "cytoscape-popper";
import * as React from "react";
import ReactDOM from "react-dom";
import styled from "styled-components";
import tippy, { Instance } from "tippy.js";
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../constants";
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../../constants";
const EntireWindowDiv = styled.div`
position: absolute;
top: 50px;
bottom: 0;
right: 0;
left: 0;
const CytoscapeContainer = styled.div`
width: 100%;
height: 100%;
`;
interface ICytoscapeProps {
currentNodeId: string | null;
elements: cytoscape.ElementsDefinition;
onInstanceSelect: (domain: string) => void;
onInstanceDeselect: () => void;
navigateToInstancePath: (domain: string) => void;
navigateToRoot: () => void;
}
class Cytoscape extends React.Component<ICytoscapeProps> {
public cy?: cytoscape.Core;
private cy?: cytoscape.Core;
public shouldComponentUpdate(prevProps: ICytoscapeProps) {
// We only want to update this component if the current instance selection changes.
// We know that the `elements` prop will never change so we skip the expensive computations here.
return prevProps.currentNodeId !== this.props.currentNodeId;
}
public componentDidMount() {
const container = ReactDOM.findDOMNode(this);
cytoscape.use(popper as any);
this.cy = cytoscape({
autoungrabify: true,
container: container as any,
@ -90,11 +92,11 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
"font-size": 50,
"min-zoomed-font-size": 16
})
.selector(".hidden")
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected
.style({
display: "none"
})
.selector(".thickEdge")
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
.style({
width: 2
})
@ -102,8 +104,8 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
this.cy.nodes().on("select", e => {
const instanceId = e.target.data("id");
if (instanceId) {
this.props.onInstanceSelect(instanceId);
if (instanceId && instanceId !== this.props.currentNodeId) {
this.props.navigateToInstancePath(instanceId);
}
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
@ -119,7 +121,6 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
});
});
this.cy.nodes().on("unselect", e => {
this.props.onInstanceDeselect();
this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge");
@ -128,14 +129,17 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
this.cy.on("click", e => {
// Clicking on the background should also deselect
const target = e.target;
if (!target) {
this.props.onInstanceDeselect();
if (!target || target === this.cy || target.isEdge()) {
// Go to the URL "/"
this.props.navigateToRoot();
}
this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge");
});
});
this.setNodeSelection();
}
public componentDidUpdate(prevProps: ICytoscapeProps) {
this.setNodeSelection(prevProps.currentNodeId);
}
public componentWillUnmount() {
@ -145,8 +149,47 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
}
public render() {
return <EntireWindowDiv />;
return <CytoscapeContainer />;
}
public resetGraphPosition() {
if (!this.cy) {
return;
}
const { currentNodeId } = this.props;
if (currentNodeId) {
this.cy.zoom({
level: 0.2,
position: this.cy.$id(currentNodeId).position()
});
} else {
this.cy.zoom({
level: 0.2,
position: { x: 0, y: 0 }
});
}
}
/**
* Updates cytoscape's internal state to match our props.
*/
private setNodeSelection = (prevNodeId?: string | null) => {
if (!this.cy) {
return;
}
if (prevNodeId) {
this.cy.$id(prevNodeId).unselect();
}
const { currentNodeId } = this.props;
if (currentNodeId) {
// Select instance
this.cy.$id(currentNodeId).select();
// Center it
const selected = this.cy.$id(currentNodeId);
this.cy.center(selected);
}
};
}
export default Cytoscape;

View File

@ -0,0 +1,7 @@
import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
const ErrorState: React.SFC = () => <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} />;
export default ErrorState;

View File

@ -1,12 +1,13 @@
import { Button } from "@blueprintjs/core";
import * as React from "react";
import FloatingCard from "./FloatingCard";
import { FloatingCard } from "../atoms/";
interface IFloatingResetButtonProps {
onClick?: () => any;
}
export const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
<FloatingCard>
<Button icon="compass" onClick={onClick} />
</FloatingCard>
);
export default FloatingResetButton;

View File

@ -0,0 +1,3 @@
export { default as Cytoscape } from "./Cytoscape";
export { default as ErrorState } from "./ErrorState";
export { default as FloatingResetButton } from "./FloatingResetButton";

View File

@ -0,0 +1,76 @@
import * as React from "react";
import { connect } from "react-redux";
import { push } from "connected-react-router";
import { Dispatch } from "redux";
import styled from "styled-components";
import { IAppState, IGraph } from "../../redux/types";
import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
const GraphDiv = styled.div`
flex: 3;
`;
// TODO: merge this component with Cytoscape.tsx
interface IGraphProps {
graph?: IGraph;
currentInstanceName: string | null;
navigate: (path: string) => void;
}
class GraphImpl extends React.Component<IGraphProps> {
private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: IGraphProps) {
super(props);
this.cytoscapeComponent = React.createRef();
}
public render() {
if (!this.props.graph) {
return <ErrorState />;
}
return (
<GraphDiv>
<Cytoscape
currentNodeId={this.props.currentInstanceName}
elements={this.props.graph}
navigateToInstancePath={this.navigateToInstancePath}
navigateToRoot={this.navigateToRoot}
ref={this.cytoscapeComponent}
/>
<FloatingResetButton onClick={this.resetGraphPosition} />
</GraphDiv>
);
}
private resetGraphPosition = () => {
if (this.cytoscapeComponent.current) {
this.cytoscapeComponent.current.resetGraphPosition();
}
};
private navigateToInstancePath = (domain: string) => {
this.props.navigate(`/instance/${domain}`);
};
private navigateToRoot = () => {
this.props.navigate("/");
};
}
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
graph: state.data.graph
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
navigate: (path: string) => dispatch(push(path))
});
const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph;

View File

@ -6,14 +6,15 @@ import { Button, MenuItem } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { IItemRendererProps, ItemPredicate, Select } from "@blueprintjs/select";
import { RouteComponentProps, withRouter } from "react-router";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IInstance } from "../redux/types";
import { push } from "connected-react-router";
import { IAppState, IInstance } from "../../redux/types";
import { domainMatchSelector } from "../../util";
interface IInstanceSearchProps extends RouteComponentProps {
interface IInstanceSearchProps {
currentInstanceName: string | null;
pathname: string;
instances?: IInstance[];
selectAndLoadInstance: (instanceName: string) => void;
selectInstance: (instanceName: string) => void;
}
const InstanceSelect = Select.ofType<IInstance>();
@ -26,7 +27,9 @@ class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
itemRenderer={this.itemRenderer}
onItemSelect={this.onItemSelect}
itemPredicate={this.itemPredicate}
disabled={!this.props.instances || this.props.location.pathname !== "/"}
disabled={
!this.props.instances || (!this.props.pathname.startsWith("/instance/") && this.props.pathname !== "/")
}
initialContent={this.renderInitialContent()}
noResults={this.renderNoResults()}
popoverProps={{ popoverClassName: "fediverse-instance-search-popover" }}
@ -66,20 +69,23 @@ class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
};
private onItemSelect = (item: IInstance, event?: React.SyntheticEvent<HTMLElement>) => {
this.props.selectAndLoadInstance(item.name);
this.props.selectInstance(item.name);
};
}
const mapStateToProps = (state: IAppState) => ({
currentInstanceName: state.currentInstance.currentInstanceName,
instances: state.data.instances
});
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
instances: state.data.instances,
pathname: state.router.location.pathname
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
selectInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
});
export const InstanceSearch = withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(InstanceSearchImpl)
);
const InstanceSearch = connect(
mapStateToProps,
mapDispatchToProps
)(InstanceSearchImpl);
export default InstanceSearch;

View File

@ -0,0 +1,56 @@
import * as React from "react";
import { Alignment, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom";
import { InstanceSearch } from ".";
import { IInstanceDomainPath } from "../../constants";
interface INavState {
aboutIsOpen: boolean;
}
const linkIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => {
return location.pathname === "/" || location.pathname.startsWith("/instance/");
};
class Nav extends React.Component<{}, INavState> {
constructor(props: any) {
super(props);
this.state = { aboutIsOpen: false };
}
public render() {
return (
<Navbar fixedToTop={true}>
<Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>fediverse.space</Navbar.Heading>
<Navbar.Divider />
<NavLink
to="/"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
activeClassName={Classes.INTENT_PRIMARY}
isActive={linkIsActive as any}
>
Home
</NavLink>
<NavLink
to="/about"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
activeClassName={Classes.INTENT_PRIMARY}
exact={true}
>
About
</NavLink>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<InstanceSearch />
</Navbar.Group>
</Navbar>
);
}
}
export default Nav;

View File

@ -3,7 +3,6 @@ import moment from "moment";
import * as numeral from "numeral";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import sanitize from "sanitize-html";
import {
@ -27,10 +26,53 @@ import {
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IGraph, IInstanceDetails } from "../redux/types";
import FullDiv from "./atoms/FullDiv";
import { ErrorState } from "./ErrorState";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
import { domainMatchSelector } from "../../util";
import { ErrorState } from "../molecules/";
import { FullDiv } from "../styled-components";
interface IClosedProp {
closed?: boolean;
}
const SidebarContainer = styled.div<IClosedProp>`
position: fixed;
top: 50px;
bottom: 0;
right: ${props => (props.closed ? "-400px" : 0)};
min-width: 400px;
width: 25%;
z-index: 20;
overflow: scroll;
overflow-x: hidden;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
@media screen and (min-width: 1600px) {
right: ${props => (props.closed ? "-25%" : 0)};
}
`;
const StyledCard = styled(Card)`
min-height: 100%;
width: 100%;
`;
const StyledButton = styled(Button)`
position: absolute;
top: 0;
left: -40px;
z-index: 20;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
`;
const StyledHTMLTable = styled(HTMLTable)`
width: 100%;
`;
const StyledLinkToFdNetwork = styled.div`
margin-top: 3em;
text-align: center;
`;
interface ISidebarProps {
graph?: IGraph;
@ -38,7 +80,6 @@ interface ISidebarProps {
instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean;
selectAndLoadInstance: (instanceName: string) => void;
}
interface ISidebarState {
isOpen: boolean;
@ -63,21 +104,12 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
}
public render() {
const closedClass = this.state.isOpen ? "" : " closed";
const buttonIcon = this.state.isOpen ? IconNames.DOUBLE_CHEVRON_RIGHT : IconNames.DOUBLE_CHEVRON_LEFT;
return (
<div>
<Button
onClick={this.handleToggle}
large={true}
icon={buttonIcon}
className={"fediverse-sidebar-toggle-button" + closedClass}
minimal={true}
/>
<Card className={"fediverse-sidebar" + closedClass} elevation={Elevation.TWO}>
{this.renderSidebarContents()}
</Card>
</div>
<SidebarContainer closed={!this.state.isOpen}>
<StyledButton onClick={this.handleToggle} large={true} icon={buttonIcon} minimal={true} />
<StyledCard elevation={Elevation.TWO}>{this.renderSidebarContents()}</StyledCard>
</SidebarContainer>
);
}
@ -149,6 +181,16 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
</Tabs>
<StyledLinkToFdNetwork>
<a
href={`https://fediverse.network/${this.props.instanceName}`}
target="_blank"
rel="noopener noreferrer"
className={`${Classes.BUTTON} bp3-icon-${IconNames.LINK}`}
>
See more statistics at fediverse.network
</a>
</StyledLinkToFdNetwork>
</div>
);
};
@ -196,7 +238,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails;
return (
<div>
<HTMLTable small={true} striped={true} className="fediverse-sidebar-table">
<StyledHTMLTable small={true} striped={true}>
<tbody>
<tr>
<td>Version</td>
@ -238,7 +280,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr>
</tbody>
</HTMLTable>
</StyledHTMLTable>
</div>
);
};
@ -261,9 +303,13 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
const neighborRows = orderBy(neighbors, ["weight"], ["desc"]).map((neighborDetails: any, idx: number) => (
<tr key={idx}>
<td>
<AnchorButton minimal={true} onClick={this.selectInstance}>
<Link
to={`/instance/${neighborDetails.neighbor}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL}`}
role="button"
>
{neighborDetails.neighbor}
</AnchorButton>
</Link>
</td>
<td>{neighborDetails.weight.toFixed(4)}</td>
</tr>
@ -274,7 +320,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
The mention ratio is the average of how many times the two instances mention each other per status. A mention
ratio of 1 would mean that every single status contained a mention of a user on the other instance.
</p>
<HTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<StyledHTMLTable small={true} striped={true} interactive={false}>
<thead>
<tr>
<th>Instance</th>
@ -282,7 +328,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
</tr>
</thead>
<tbody>{neighborRows}</tbody>
</HTMLTable>
</StyledHTMLTable>
</div>
);
};
@ -293,11 +339,11 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
return;
}
const peerRows = peers.map(instance => (
<tr key={instance.name} onClick={this.selectInstance}>
<tr key={instance.name}>
<td>
<AnchorButton minimal={true} onClick={this.selectInstance}>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{instance.name}
</AnchorButton>
</Link>
</td>
</tr>
));
@ -306,9 +352,9 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
<p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about.
</p>
<HTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody>
</HTMLTable>
</StyledHTMLTable>
</div>
);
};
@ -399,23 +445,17 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
private openInstanceLink = () => {
window.open("https://" + this.props.instanceName, "_blank");
};
private selectInstance = (e: any) => {
this.props.selectAndLoadInstance(e.target.innerText);
};
}
const mapStateToProps = (state: IAppState) => ({
graph: state.data.graph,
instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error,
instanceName: state.currentInstance.currentInstanceName,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
export const Sidebar = connect(
mapStateToProps,
mapDispatchToProps
)(SidebarImpl);
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
graph: state.data.graph,
instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
};
};
const Sidebar = connect(mapStateToProps)(SidebarImpl);
export default Sidebar;

View File

@ -0,0 +1,4 @@
export { default as Graph } from "./Graph";
export { default as Sidebar } from "./Sidebar";
export { default as Nav } from "./Nav";
export { default as InstanceSearch } from "./InstanceSearch";

View File

@ -1,8 +1,8 @@
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react";
import { Page } from "../Page";
import { Page } from "../atoms/";
export const AboutScreen: React.FC = () => (
const AboutScreen: React.FC = () => (
<Page>
<H1>About</H1>
<p className={Classes.RUNNING_TEXT}>
@ -85,3 +85,4 @@ export const AboutScreen: React.FC = () => (
</p>
</Page>
);
export default AboutScreen;

View File

@ -1,60 +1,82 @@
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { NonIdealState, Spinner } from "@blueprintjs/core";
import { fetchGraph, fetchInstances } from "../../redux/actions";
import { IAppState, IGraph, IInstance } from "../../redux/types";
import { ErrorState } from "../ErrorState";
import Graph from "../Graph";
import { Sidebar } from "../Sidebar";
import { fetchGraph, fetchInstances, loadInstance } from "../../redux/actions";
import { IAppState } from "../../redux/types";
import { domainMatchSelector } from "../../util";
import { ErrorState } from "../molecules/";
import { Graph, Sidebar } from "../organisms/";
const GraphContainer = styled.div`
display: flex;
height: 100%;
width: 100%;
`;
const FullDiv = styled.div`
position: absolute;
top: 50px;
bottom: 0;
left: 0;
right: 0;
`;
interface IGraphScreenProps {
graph?: IGraph;
instances?: IInstance[];
currentInstanceName: string | null;
pathname: string;
isLoadingGraph: boolean;
isLoadingInstances: boolean;
graphLoadError: boolean;
loadInstance: (domain: string | null) => void;
fetchInstances: () => void;
fetchGraph: () => void;
}
/**
* This component takes care of loading or deselecting the current instance when the URL path changes.
*/
class GraphScreenImpl extends React.Component<IGraphScreenProps> {
public render() {
let body = <div />;
let content;
if (this.props.isLoadingInstances || this.props.isLoadingGraph) {
body = this.loadingState("Loading...");
content = this.loadingState("Loading...");
} else if (!!this.props.graphLoadError) {
content = <ErrorState />;
} else {
body = this.graphState();
content = (
<GraphContainer>
<Graph />
<Sidebar />
</GraphContainer>
);
}
return <div>{body}</div>;
return <FullDiv>{content}</FullDiv>;
}
public componentDidMount() {
this.load();
this.loadInstancesAndGraph();
this.loadCurrentInstance();
}
public componentDidUpdate() {
this.load();
public componentDidUpdate(prevProps: IGraphScreenProps) {
this.loadCurrentInstance(prevProps.currentInstanceName);
}
private load = () => {
if (!this.props.instances && !this.props.isLoadingInstances && !this.props.graphLoadError) {
this.props.fetchInstances();
}
if (!this.props.graph && !this.props.isLoadingGraph && !this.props.graphLoadError) {
private loadInstancesAndGraph = () => {
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
this.props.fetchGraph();
}
if (!this.props.isLoadingInstances && !this.props.graphLoadError) {
this.props.fetchInstances();
}
};
private graphState = () => {
const content = this.props.graphLoadError ? <ErrorState /> : <Graph />;
return (
<div>
<Sidebar />
{content}
</div>
);
private loadCurrentInstance = (prevInstanceName?: string | null) => {
if (prevInstanceName !== this.props.currentInstanceName) {
this.props.loadInstance(this.props.currentInstanceName);
}
};
private loadingState = (title?: string) => {
@ -62,18 +84,25 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps> {
};
}
const mapStateToProps = (state: IAppState) => ({
graph: state.data.graph,
graphLoadError: state.data.error,
instances: state.data.instances,
isLoadingGraph: state.data.isLoadingGraph,
isLoadingInstances: state.data.isLoadingInstances
});
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
graph: state.data.graph,
graphLoadError: state.data.error,
instances: state.data.instances,
isLoadingGraph: state.data.isLoadingGraph,
isLoadingInstances: state.data.isLoadingInstances,
pathname: state.router.location.pathname
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
fetchInstances: () => dispatch(fetchInstances() as any)
fetchInstances: () => dispatch(fetchInstances() as any),
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
});
export const GraphScreen = connect(
const GraphScreen = connect(
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);
export default GraphScreen;

View File

@ -0,0 +1,2 @@
export { default as AboutScreen } from "./AboutScreen";
export { default as GraphScreen } from "./GraphScreen";

View File

@ -1,6 +1,6 @@
import styled from "styled-components";
export default styled.div`
export const FullDiv = styled.div`
width: 100%;
height: 100%;
`;

View File

@ -3,3 +3,8 @@ export const DESKTOP_WIDTH_THRESHOLD = 800;
export const DEFAULT_NODE_COLOR = "#CED9E0";
export const SELECTED_NODE_COLOR = "#48AFF0";
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath {
domain: string;
}

View File

@ -3,7 +3,6 @@ body {
margin: 0;
padding: 50px 0 0 0;
font-family: sans-serif;
/*background-color: #30404D;*/
background-color: #293742;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue,
@ -15,50 +14,3 @@ body {
min-width: 300px;
overflow-x: hidden;
}
.fediverse-sidebar {
position: fixed;
top: 50px;
bottom: 0;
right: 0;
min-width: 400px;
width: 25%;
z-index: 20;
overflow: scroll;
overflow-x: hidden;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.fediverse-sidebar.closed {
right: -400px;
}
.fediverse-sidebar-toggle-button {
position: absolute;
top: 50px;
right: 400px;
z-index: 20;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.fediverse-sidebar-toggle-button.closed {
right: 0;
}
.fediverse-sidebar-table {
width: 100%;
}
@media screen and (min-width: 1600px) {
.fediverse-sidebar.closed {
right: -25%;
}
.fediverse-sidebar-toggle-button {
right: 25%;
}
}

View File

@ -4,6 +4,8 @@ import "../node_modules/@blueprintjs/select/lib/css/blueprint-select.css";
import "../node_modules/normalize.css/normalize.css";
import "./index.css";
import cytoscape from "cytoscape";
import popper from "cytoscape-popper";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Provider } from "react-redux";
@ -12,16 +14,26 @@ import thunk from "redux-thunk";
import { FocusStyleManager } from "@blueprintjs/core";
import { routerMiddleware } from "connected-react-router";
import { createBrowserHistory } from "history";
import { AppRouter } from "./AppRouter";
import { rootReducer } from "./redux/reducers";
import createRootReducer from "./redux/reducers";
// https://blueprintjs.com/docs/#core/accessibility.focus-management
FocusStyleManager.onlyShowFocusOnTabs();
export const history = createBrowserHistory();
// Initialize redux
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));
const store = createStore(
createRootReducer(history),
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))
);
// Initialize cytoscape plugins
cytoscape.use(popper as any);
ReactDOM.render(
<Provider store={store}>
@ -30,8 +42,8 @@ ReactDOM.render(
document.getElementById("root") as HTMLElement
);
if (process.env.NODE_ENV !== "production") {
// tslint:disable-next-line:no-var-requires
const axe = require("react-axe");
axe(React, ReactDOM, 5000);
}
// if (process.env.NODE_ENV !== "production") {
// // tslint:disable-next-line:no-var-requires
// const axe = require("react-axe");
// axe(React, ReactDOM, 5000);
// }

View File

@ -1,13 +1,14 @@
import { Dispatch } from "redux";
import { push } from "connected-react-router";
import { getFromApi } from "../util";
import { ActionType, IGraph, IInstance, IInstanceDetails } from "./types";
import { ActionType, IAppState, IGraph, IInstance, IInstanceDetails } from "./types";
// selectInstance and deselectInstance are not exported since we only call them from selectAndLoadInstance()
const selectInstance = (instanceName: string) => {
// requestInstanceDetails and deselectInstance are not exported since we only call them from loadInstance()
const requestInstanceDetails = (instanceName: string) => {
return {
payload: instanceName,
type: ActionType.SELECT_INSTANCE
type: ActionType.REQUEST_INSTANCE_DETAILS
};
};
const deselectInstance = () => {
@ -71,13 +72,16 @@ export const fetchInstances = () => {
};
};
export const selectAndLoadInstance = (instanceName: string) => {
return (dispatch: Dispatch) => {
export const loadInstance = (instanceName: string | null) => {
return (dispatch: Dispatch, getState: () => IAppState) => {
if (!instanceName) {
dispatch(deselectInstance());
if (getState().router.location.pathname.startsWith("/instance/")) {
dispatch(push("/"));
}
return;
}
dispatch(selectInstance(instanceName));
dispatch(requestInstanceDetails(instanceName));
return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details)))
.catch(e => dispatch(instanceLoadFailed()));

View File

@ -1,5 +1,7 @@
import { connectRouter } from "connected-react-router";
import { combineReducers } from "redux";
import { History } from "history";
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types";
const initialDataState = {
@ -46,29 +48,29 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
const initialCurrentInstanceState: ICurrentInstanceState = {
currentInstanceDetails: null,
currentInstanceName: null,
error: false,
isLoadingInstanceDetails: false
};
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => {
switch (action.type) {
case ActionType.SELECT_INSTANCE:
case ActionType.REQUEST_INSTANCE_DETAILS:
return {
...state,
currentInstanceName: action.payload,
error: false,
isLoadingInstanceDetails: true
};
case ActionType.RECEIVE_INSTANCE_DETAILS:
return {
...state,
currentInstanceDetails: action.payload,
error: false,
isLoadingInstanceDetails: false
};
case ActionType.DESELECT_INSTANCE:
return {
...state,
currentInstanceDetails: null,
currentInstanceName: null
error: false
};
case ActionType.INSTANCE_LOAD_ERROR:
return {
@ -81,7 +83,10 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
}
};
export const rootReducer = combineReducers({
currentInstance,
data
});
export default (history: History) =>
combineReducers({
router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys
currentInstance,
data
});

View File

@ -1,5 +1,7 @@
import { RouterState } from "connected-react-router";
export enum ActionType {
SELECT_INSTANCE = "SELECT_INSTANCE",
REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS",
REQUEST_INSTANCES = "REQUEST_INSTANCES",
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
REQUEST_GRAPH = "REQUEST_GRAPH",
@ -60,9 +62,9 @@ export interface IGraph {
// Redux state
// The current instance name is stored in the URL. See state -> router -> location
export interface ICurrentInstanceState {
currentInstanceDetails: IInstanceDetails | null;
currentInstanceName: string | null;
isLoadingInstanceDetails: boolean;
error: boolean;
}
@ -76,6 +78,7 @@ export interface IDataState {
}
export interface IAppState {
router: RouterState;
currentInstance: ICurrentInstanceState;
data: IDataState;
}

View File

@ -1,4 +1,7 @@
import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch";
import { IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types";
let API_ROOT = "http://localhost:4000/api/";
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
@ -12,3 +15,5 @@ export const getFromApi = (path: string): Promise<any> => {
path = path.endsWith("/") ? path : path + "/";
return fetch(domain + path).then(response => response.json());
};
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);

View File

@ -2966,6 +2966,15 @@ connect-history-api-fallback@^1.3.0:
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc"
integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==
connected-react-router@^6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.5.2.tgz#422af70f86cb276681e20ab4295cf27dd9b6c7e3"
integrity sha512-qzsLPZCofSI80fwy+HgxtEgSGS4ndYUUZAWaw1dqaOGPLKX/FVwIOEb7q+hjHdnZ4v5pKZcNv5GG4urjujIoyA==
dependencies:
immutable "^3.8.1"
prop-types "^15.7.2"
seamless-immutable "^7.1.3"
console-browserify@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
@ -5245,6 +5254,11 @@ immer@1.10.0:
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
immutable@^3.8.1:
version "3.8.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
import-cwd@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@ -9556,6 +9570,11 @@ schema-utils@^1.0.0:
ajv-errors "^1.0.0"
ajv-keywords "^3.1.0"
seamless-immutable@^7.1.3:
version "7.1.4"
resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8"
integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"