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 { AppState, Graph, GraphResponse, InstanceDetails, Peer } from "../../redux/types"; import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util"; import { InstanceType } from "../atoms"; import { Cytoscape, ErrorState } from "../molecules"; import { FederationTab } from "../organisms"; 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 NeighborsCallout = styled(Callout)` margin: 10px 0; width: auto; `; const StyledTabs = (styled as any)(Tabs)` width: 100%; padding: 0 20px; `; const StyledGraphContainer = styled.div` flex-grow: 1; display: flex; flex-direction: column; margin-bottom: 10px; `; interface InstanceScreenProps { graph?: Graph; instanceName: string | null; instanceLoadError: boolean; instanceDetails: InstanceDetails | null; isLoadingInstanceDetails: boolean; navigateToRoot: () => void; navigateToInstance: (domain: string) => void; } interface InstanceScreenState { neighbors?: string[]; isProcessingNeighbors: boolean; // Local (neighborhood) graph. Used only on small screens (mobile devices). isLoadingLocalGraph: boolean; localGraph?: Graph; localGraphLoadError?: boolean; } class InstanceScreenImpl extends React.PureComponent { public constructor(props: InstanceScreenProps) { 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 || !this.props.instanceDetails.status ) { content = ; } else if (this.props.instanceDetails.status.toLowerCase().includes("personal instance")) { content = this.renderPersonalInstanceErrorState(); } else if (this.props.instanceDetails.status.toLowerCase().includes("robots.txt")) { content = this.renderRobotsTxtState(); } else if (this.props.instanceDetails.status !== "success") { content = this.renderMissingDataState(); } else { content = this.renderTabs(); } return ( {this.props.instanceName} {content} ); } public componentDidMount() { this.loadLocalGraphOnSmallScreen(); this.processEdgesToFindNeighbors(); } public componentDidUpdate(prevProps: InstanceScreenProps, prevState: InstanceScreenState) { 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 || localGraph; if (!graphToUse) { return; } const edges = graphToUse.edges.filter((e) => [e.data.source, e.data.target].includes(instanceName)); 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: GraphResponse) => { // 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 { graph } = response; const nodeIds = new Set(graph.nodes.map((n) => n.data.id)); const edges = graph.edges.filter((e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target)); this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } }); }) .catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true })); }; private renderTabs = () => { const { instanceDetails } = this.props; const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0; const federationRestrictions = instanceDetails && instanceDetails.federationRestrictions; 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 ? (

This instance doesn't have any neighbors that we know of, so it's hidden from the graph.

) : undefined; return ( <> {insularCallout} {this.maybeRenderLocalGraph()} {instanceDetails && instanceDetails.description && ( )} {this.shouldRenderStats() && } {federationRestrictions && Object.keys(federationRestrictions).length > 0 && ( } /> )} ); }; private maybeRenderLocalGraph = () => { const { localGraph } = this.state; const hasLocalGraph = !!localGraph && localGraph.nodes.length > 0 && localGraph.edges.length > 0; if (!hasLocalGraph || !localGraph) { return; } return ( ); }; private shouldRenderStats = () => { const details = this.props.instanceDetails; return details && (details.version || details.userCount || details.statusCount || details.domainCount); }; private renderDescription = () => { const { instanceDetails } = this.props; if (!instanceDetails) { return; } const { description } = instanceDetails; if (!description) { return; } return

; }; private renderVersionAndCounts = () => { if (!this.props.instanceDetails) { throw new Error("Did not receive instance details as expected!"); } const { version, userCount, statusCount, domainCount, lastUpdated, insularity, type, statusesPerDay, statusesPerUserPerDay, } = this.props.instanceDetails; return ( Version {{version} || "Unknown"} Instance type {(type && ) || "Unknown"} Users {(userCount && numeral.default(userCount).format("0,0")) || "Unknown"} Statuses {(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"} Insularity {" "} The percentage of mentions that are directed
toward users on the same instance. } position={Position.TOP} className={Classes.DARK} >
{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"} Statuses / day {" "} The average number of statuses written each day on this instance,
over the last month. } position={Position.TOP} className={Classes.DARK} >
{(statusesPerDay && numeral.default(statusesPerDay).format("0.0")) || "Unknown"} Statuses / person / day {" "} The average number of statuses written per person each day,
over the last month. } position={Position.TOP} className={Classes.DARK} >
{(statusesPerUserPerDay && numeral.default(statusesPerUserPerDay).format("0.000")) || "Unknown"} Known peers {(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"} Last updated {moment(`${lastUpdated}Z`).fromNow() || "Unknown"}
); }; private renderNeighbors = () => { if (!this.state.neighbors) { return; } const neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map( (neighborDetails: any, idx: number) => ( {neighborDetails.neighbor} {neighborDetails.weight.toFixed(4)} ) ); return (

Instances that {this.props.instanceName} has blocked may appear on this list and vice versa. This can happen if users attempt to mention someone on an instance that has blocked them.

The mention ratio is how often people on the two instances mention each other per status. A mention ratio of 1 would mean that every single status on {this.props.instanceName} contained a mention of someone on the other instance, and vice versa.

Instance Mention ratio {neighborRows}
); }; private renderPeers = () => { const { instanceDetails } = this.props; if (!instanceDetails) { return; } const { peers } = instanceDetails; if (!peers || peers.length === 0) { return; } const peerRows = peers.map((instance: Peer) => ( {instance.name} )); return (

All the instances, past and present, that {this.props.instanceName} knows about.

{peerRows}
); }; private renderLoadingState = () => } />; private renderPersonalInstanceErrorState = () => ( Opt in } /> ); private renderMissingDataState = () => ( <> {this.props.instanceDetails && this.props.instanceDetails.status} ); private renderRobotsTxtState = () => ( 🤖 } 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: AppState) => { const match = domainMatchSelector(state); return { graph: state.data.graphResponse && state.data.graphResponse.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;