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 { 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 { 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 = ); } 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 ( {this.props.instanceName} {content} ); } 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 = () => { 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 ? (

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

) : ( undefined ); return ( <> {insularCallout} {this.maybeRenderLocalGraph()} {this.props.instanceDetails!.description && ( )} {this.shouldRenderStats() && } ); }; 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 ( ); }; 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

; }; private renderVersionAndCounts = () => { if (!this.props.instanceDetails) { throw new Error("Did not receive instance details as expected!"); } const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails; return ( Version {{version} || "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"} 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 (

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.

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

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

{peerRows}
); }; private renderLoadingState = () => } />; private renderPersonalInstanceErrorState = () => { return ( Message @fediversespace to opt in } /> ); }; private renderMissingDataState = () => { return ( <> {this.props.instanceDetails && this.props.instanceDetails.status} ); }; private renderRobotsTxtState = () => { return ( 🤖 } 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;