index.community/frontend/src/components/screens/InstanceScreen.tsx

449 lines
15 KiB
TypeScript

import { orderBy } from "lodash";
import moment from "moment";
import * as numeral from "numeral";
import React from "react";
import { connect } from "react-redux";
import sanitize from "sanitize-html";
import {
AnchorButton,
Button,
Callout,
Classes,
Code,
Divider,
H2,
HTMLTable,
Icon,
NonIdealState,
Position,
Spinner,
Tab,
Tabs,
Tooltip
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router";
import { Link } from "react-router-dom";
import { Dispatch } from "redux";
import styled from "styled-components";
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
import { InstanceType } from "../atoms";
import { Cytoscape, ErrorState } from "../molecules/";
const InstanceScreenContainer = styled.div`
margin-bottom: auto;
display: flex;
flex-direction: column;
flex: 1;
`;
const HeadingContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
padding: 0 20px;
`;
const StyledHeadingH2 = styled(H2)`
margin: 0;
`;
const StyledCloseButton = styled(Button)`
justify-self: flex-end;
`;
const StyledHeadingTooltip = styled(Tooltip)`
margin-left: 5px;
flex-grow: 1;
`;
const StyledHTMLTable = styled(HTMLTable)`
width: 100%;
`;
const StyledLinkToFdNetwork = styled.div`
text-align: center;
margin-top: auto;
`;
const StyledCallout = styled(Callout)`
margin: 10px 20px;
width: auto;
`;
const StyledTabs = styled(Tabs)`
width: 100%;
padding: 0 20px;
`;
const StyledGraphContainer = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
margin-bottom: 10px;
`;
interface IInstanceScreenProps {
graph?: IGraph;
instanceName: string | null;
instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean;
navigateToRoot: () => void;
navigateToInstance: (domain: string) => void;
}
interface IInstanceScreenState {
neighbors?: string[];
isProcessingNeighbors: boolean;
// Local (neighborhood) graph. Used only on small screens (mobile devices).
isLoadingLocalGraph: boolean;
localGraph?: IGraph;
localGraphLoadError?: boolean;
}
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
public constructor(props: IInstanceScreenProps) {
super(props);
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
}
public render() {
let content;
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors || this.state.isLoadingLocalGraph) {
content = this.renderLoadingState();
} else if (this.props.instanceLoadError || this.state.localGraphLoadError || !this.props.instanceDetails) {
return (content = <ErrorState />);
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState();
} else {
content = this.renderTabs();
}
return (
<InstanceScreenContainer>
<HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer>
<Divider />
{content}
</InstanceScreenContainer>
);
}
public componentDidMount() {
this.loadLocalGraphOnSmallScreen();
this.processEdgesToFindNeighbors();
}
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
const isNewInstance = prevProps.instanceName !== this.props.instanceName;
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
if (isNewInstance || receivedNewEdges || receivedNewLocalGraph) {
this.processEdgesToFindNeighbors();
}
}
private processEdgesToFindNeighbors = () => {
// TODO: use cytoscape to replace this method
// simply cy.$id(nodeId).outgoers() (and/or incomers())
const { graph, instanceName } = this.props;
const { localGraph } = this.state;
if ((!graph && !localGraph) || !instanceName) {
return;
}
this.setState({ isProcessingNeighbors: true });
const graphToUse = !!graph ? graph : localGraph;
const edges = graphToUse!.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1);
const neighbors: any[] = [];
edges.forEach(e => {
if (e.data.source === instanceName) {
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
} else {
neighbors.push({ neighbor: e.data.source, weight: e.data.weight });
}
});
this.setState({ neighbors, isProcessingNeighbors: false });
};
private loadLocalGraphOnSmallScreen = () => {
if (!isSmallScreen) {
return;
}
this.setState({ isLoadingLocalGraph: true });
getFromApi(`graph/${this.props.instanceName}`)
.then((response: IGraph) => {
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood
// We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
// queries.
// TODO: think more about moving the backend to a graph database that would make this easier.
const nodeIds = new Set(response.nodes.map(n => n.data.id));
const edges = response.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
this.setState({ isLoadingLocalGraph: false, localGraph: { ...response, edges } });
})
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
};
private renderTabs = () => {
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const hasLocalGraph =
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
const insularCallout =
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance">
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
</StyledCallout>
) : (
undefined
);
return (
<>
{insularCallout}
{this.maybeRenderLocalGraph()}
<StyledTabs>
{this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} />
)}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
</StyledTabs>
<StyledLinkToFdNetwork>
<AnchorButton
href={`https://fediverse.network/${this.props.instanceName}`}
minimal={true}
rightIcon={IconNames.SHARE}
target="_blank"
text="See more statistics at fediverse.network"
/>
</StyledLinkToFdNetwork>
</>
);
};
private maybeRenderLocalGraph = () => {
const { localGraph } = this.state;
const hasLocalGraph =
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
if (!hasLocalGraph) {
return;
}
return (
<StyledGraphContainer>
<Cytoscape
elements={localGraph!}
currentNodeId={this.props.instanceName}
navigateToInstancePath={this.props.navigateToInstance}
/>
<Divider />
</StyledGraphContainer>
);
};
private shouldRenderStats = () => {
const details = this.props.instanceDetails;
return details && (details.version || details.userCount || details.statusCount || details.domainCount);
};
private renderDescription = () => {
const description = this.props.instanceDetails!.description;
if (!description) {
return;
}
return <p className={Classes.RUNNING_TEXT} dangerouslySetInnerHTML={{ __html: sanitize(description) }} />;
};
private renderVersionAndCounts = () => {
if (!this.props.instanceDetails) {
throw new Error("Did not receive instance details as expected!");
}
const { version, userCount, statusCount, domainCount, lastUpdated, insularity, type } = this.props.instanceDetails;
return (
<StyledHTMLTable small={true} striped={true}>
<tbody>
<tr>
<td>Version</td>
<td>{<Code>{version}</Code> || "Unknown"}</td>
</tr>
<tr>
<td>Instance type</td>
<td>{(type && <InstanceType type={type} colorAfterName={true} />) || "Unknown"}</td>
</tr>
<tr>
<td>Users</td>
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>Statuses</td>
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>
Insularity{" "}
<Tooltip
content={
<span>
The percentage of mentions that are directed
<br />
toward users on the same instance.
</span>
}
position={Position.TOP}
className={Classes.DARK}
>
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
</Tooltip>
</td>
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
</tr>
<tr>
<td>Known peers</td>
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr>
</tbody>
</StyledHTMLTable>
);
};
private renderNeighbors = () => {
if (!this.state.neighbors) {
return;
}
const neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map(
(neighborDetails: any, idx: number) => (
<tr key={idx}>
<td>
<Link
to={`/instance/${neighborDetails.neighbor}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL}`}
role="button"
>
{neighborDetails.neighbor}
</Link>
</td>
<td>{neighborDetails.weight.toFixed(4)}</td>
</tr>
)
);
return (
<div>
<p className={Classes.TEXT_MUTED}>
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>
<StyledHTMLTable small={true} striped={true} interactive={false}>
<thead>
<tr>
<th>Instance</th>
<th>Mention ratio</th>
</tr>
</thead>
<tbody>{neighborRows}</tbody>
</StyledHTMLTable>
</div>
);
};
private renderPeers = () => {
const peers = this.props.instanceDetails!.peers;
if (!peers || peers.length === 0) {
return;
}
const peerRows = peers.map(instance => (
<tr key={instance.name}>
<td>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{instance.name}
</Link>
</td>
</tr>
));
return (
<div>
<p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about.
</p>
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody>
</StyledHTMLTable>
</div>
);
};
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => {
return (
<NonIdealState
icon={IconNames.BLOCKED_PERSON}
title="No data"
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
action={
<Link to={"/admin"} className={Classes.BUTTON} role="button">
{"Opt in"}
</Link>
}
/>
);
};
private renderMissingDataState = () => {
return (
<>
<NonIdealState
icon={IconNames.ERROR}
title="No data"
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
/>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
{this.props.instanceDetails && this.props.instanceDetails.status}
</span>
</>
);
};
private renderRobotsTxtState = () => {
return (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
</span>
}
title="No data"
description="This instance was not crawled because its robots.txt did not allow us to."
/>
);
};
private openInstanceLink = () => {
window.open("https://" + this.props.instanceName, "_blank");
};
}
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 mapDispatchToProps = (dispatch: Dispatch) => ({
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
navigateToRoot: () => dispatch(push("/"))
});
const InstanceScreen = connect(
mapStateToProps,
mapDispatchToProps
)(InstanceScreenImpl);
export default InstanceScreen;