experimental mobile support
This commit is contained in:
parent
1ff7cd7290
commit
e490b30ebb
|
@ -21,9 +21,11 @@ defmodule Backend.Api do
|
|||
* have a user count (required to give the instance a size on the graph)
|
||||
* the user count is > the threshold
|
||||
* have x and y coordinates
|
||||
|
||||
If `domain` is passed, then this function only returns nodes that are neighbors of that instance.
|
||||
"""
|
||||
@spec list_nodes() :: [Instance.t()]
|
||||
def list_nodes() do
|
||||
def list_nodes(domain \\ nil) do
|
||||
user_threshold = get_config(:personal_instance_threshold)
|
||||
|
||||
Instance
|
||||
|
@ -32,17 +34,38 @@ defmodule Backend.Api do
|
|||
not is_nil(i.x) and not is_nil(i.y) and not is_nil(i.user_count) and
|
||||
i.user_count >= ^user_threshold
|
||||
)
|
||||
|> maybe_filter_nodes_to_neighborhood(domain)
|
||||
|> select([c], [:domain, :user_count, :x, :y])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
# if we're getting the sub-graph around a given domain, only return neighbors.
|
||||
defp maybe_filter_nodes_to_neighborhood(query, domain) do
|
||||
case domain do
|
||||
nil ->
|
||||
query
|
||||
|
||||
_ ->
|
||||
query
|
||||
|> join(:inner, [i], outgoing_edges in Edge, on: outgoing_edges.source_domain == i.domain)
|
||||
|> join(:inner, [i], incoming_edges in Edge, on: incoming_edges.target_domain == i.domain)
|
||||
|> where(
|
||||
[i, outgoing_edges, incoming_edges],
|
||||
outgoing_edges.target_domain == ^domain or incoming_edges.source_domain == ^domain or
|
||||
i.domain == ^domain
|
||||
)
|
||||
|> distinct(true)
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_edges() :: [Edge.t()]
|
||||
def list_edges() do
|
||||
def list_edges(domain \\ nil) do
|
||||
user_threshold = get_config(:personal_instance_threshold)
|
||||
|
||||
Edge
|
||||
|> join(:inner, [e], i1 in Instance, on: e.source_domain == i1.domain)
|
||||
|> join(:inner, [e], i2 in Instance, on: e.target_domain == i2.domain)
|
||||
|> maybe_filter_edges_to_neighborhood(domain)
|
||||
|> select([e], [:id, :source_domain, :target_domain, :weight])
|
||||
|> where(
|
||||
[e, i1, i2],
|
||||
|
@ -53,6 +76,28 @@ defmodule Backend.Api do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp maybe_filter_edges_to_neighborhood(query, domain) do
|
||||
case domain do
|
||||
nil ->
|
||||
query
|
||||
|
||||
_ ->
|
||||
# we want all edges in the neighborhood -- not just edges connected to `domain`
|
||||
query
|
||||
|> join(:inner, [e], neighbor_edges in Edge,
|
||||
on:
|
||||
neighbor_edges.source_domain == e.target_domain or
|
||||
neighbor_edges.target_domain == e.source_domain
|
||||
)
|
||||
|> where(
|
||||
[e, i1, i2, neighbor_edges],
|
||||
e.source_domain == ^domain or e.target_domain == ^domain or
|
||||
neighbor_edges.source_domain == ^domain or neighbor_edges.target_domain == ^domain
|
||||
)
|
||||
|> distinct(true)
|
||||
end
|
||||
end
|
||||
|
||||
def search_instances(query, cursor_after \\ nil) do
|
||||
ilike_query = "%#{query}%"
|
||||
|
||||
|
|
|
@ -10,4 +10,10 @@ defmodule BackendWeb.GraphController do
|
|||
edges = Api.list_edges()
|
||||
render(conn, "index.json", nodes: nodes, edges: edges)
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => domain}) do
|
||||
nodes = Api.list_nodes(domain)
|
||||
edges = Api.list_edges(domain)
|
||||
render(conn, "index.json", nodes: nodes, edges: edges)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,7 +9,7 @@ defmodule BackendWeb.Router do
|
|||
pipe_through(:api)
|
||||
|
||||
resources("/instances", InstanceController, only: [:index, :show])
|
||||
resources("/graph", GraphController, only: [:index])
|
||||
resources("/graph", GraphController, only: [:index, :show])
|
||||
resources("/search", SearchController, only: [:index])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,73 +1,21 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Button, Classes, Dialog } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
import { Classes } from "@blueprintjs/core";
|
||||
|
||||
import { ConnectedRouter } from "connected-react-router";
|
||||
import { Route } from "react-router-dom";
|
||||
import { Nav } from "./components/organisms/";
|
||||
import { AboutScreen, GraphScreen } from "./components/screens/";
|
||||
import { DESKTOP_WIDTH_THRESHOLD } from "./constants";
|
||||
import { history } from "./index";
|
||||
|
||||
interface IAppLocalState {
|
||||
mobileDialogOpen: boolean;
|
||||
}
|
||||
export class AppRouter extends React.Component<{}, IAppLocalState> {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
this.state = { mobileDialogOpen: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
const AppRouter: React.FC = () => (
|
||||
<ConnectedRouter history={history}>
|
||||
<div className={`${Classes.DARK} App`}>
|
||||
<Nav />
|
||||
<Route path="/about" component={AboutScreen} />
|
||||
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
|
||||
<GraphScreen />
|
||||
{this.renderMobileDialog()}
|
||||
</div>
|
||||
</ConnectedRouter>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (window.innerWidth < DESKTOP_WIDTH_THRESHOLD) {
|
||||
this.handleMobileDialogOpen();
|
||||
}
|
||||
}
|
||||
|
||||
private renderMobileDialog = () => {
|
||||
return (
|
||||
<Dialog
|
||||
icon={IconNames.DESKTOP}
|
||||
title="Desktop-optimized site"
|
||||
onClose={this.handleMobileDialogClose}
|
||||
isOpen={this.state.mobileDialogOpen}
|
||||
className={Classes.DARK + " fediverse-about-dialog"}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<p className={Classes.RUNNING_TEXT}>
|
||||
fediverse.space is optimized for desktop computers. Feel free to check it out on your phone (ideally in
|
||||
landscape mode) but for best results, open it on a computer.
|
||||
</p>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button icon={IconNames.THUMBS_UP} text="OK!" onClick={this.handleMobileDialogClose} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
private handleMobileDialogOpen = () => {
|
||||
this.setState({ mobileDialogOpen: true });
|
||||
};
|
||||
|
||||
private handleMobileDialogClose = () => {
|
||||
this.setState({ mobileDialogOpen: false });
|
||||
};
|
||||
}
|
||||
);
|
||||
export default AppRouter;
|
||||
|
|
|
@ -13,8 +13,8 @@ const CytoscapeContainer = styled.div`
|
|||
interface ICytoscapeProps {
|
||||
currentNodeId: string | null;
|
||||
elements: cytoscape.ElementsDefinition;
|
||||
navigateToInstancePath: (domain: string) => void;
|
||||
navigateToRoot: () => void;
|
||||
navigateToInstancePath?: (domain: string) => void;
|
||||
navigateToRoot?: () => void;
|
||||
}
|
||||
class Cytoscape extends React.Component<ICytoscapeProps> {
|
||||
private cy?: cytoscape.Core;
|
||||
|
@ -105,8 +105,10 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
this.cy.nodes().on("select", e => {
|
||||
const instanceId = e.target.data("id");
|
||||
if (instanceId && instanceId !== this.props.currentNodeId) {
|
||||
if (this.props.navigateToInstancePath) {
|
||||
this.props.navigateToInstancePath(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
|
||||
// Reset graph visibility
|
||||
|
@ -130,9 +132,11 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
// Clicking on the background should also deselect
|
||||
const target = e.target;
|
||||
if (!target || target === this.cy || target.isEdge()) {
|
||||
if (this.props.navigateToRoot) {
|
||||
// Go to the URL "/"
|
||||
this.props.navigateToRoot();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setNodeSelection();
|
||||
|
|
|
@ -8,7 +8,7 @@ import { InstanceScreen, SearchScreen } from ".";
|
|||
import { INSTANCE_DOMAIN_PATH } from "../../constants";
|
||||
import { loadInstance } from "../../redux/actions";
|
||||
import { IAppState } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { domainMatchSelector, isSmallScreen } from "../../util";
|
||||
import { Graph, SidebarContainer } from "../organisms/";
|
||||
|
||||
const GraphContainer = styled.div`
|
||||
|
@ -50,7 +50,8 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps> {
|
|||
private renderRoutes = ({ location }: RouteComponentProps) => (
|
||||
<FullDiv>
|
||||
<GraphContainer>
|
||||
<Graph />
|
||||
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
|
||||
{isSmallScreen || <Graph />}
|
||||
<SidebarContainer>
|
||||
<Switch>
|
||||
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
|
||||
|
|
|
@ -29,8 +29,8 @@ import { Link } from "react-router-dom";
|
|||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { ErrorState } from "../molecules/";
|
||||
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
|
||||
import { Cytoscape, ErrorState } from "../molecules/";
|
||||
|
||||
const InstanceScreenContainer = styled.div`
|
||||
margin-bottom: auto;
|
||||
|
@ -64,6 +64,10 @@ const StyledLinkToFdNetwork = styled.div`
|
|||
const StyledTabs = styled(Tabs)`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledGraphContainer = styled.div`
|
||||
height: 50%;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
interface IInstanceScreenProps {
|
||||
graph?: IGraph;
|
||||
instanceName: string | null;
|
||||
|
@ -71,31 +75,34 @@ interface IInstanceScreenProps {
|
|||
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 };
|
||||
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
let content;
|
||||
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors) {
|
||||
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors || this.state.isLoadingLocalGraph) {
|
||||
content = this.renderLoadingState();
|
||||
} else if (!this.props.instanceDetails) {
|
||||
return this.renderEmptyState();
|
||||
} 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 if (this.props.instanceLoadError) {
|
||||
return (content = <ErrorState />);
|
||||
} else {
|
||||
content = this.renderTabs();
|
||||
}
|
||||
|
@ -115,24 +122,29 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.loadLocalGraphOnSmallScreen();
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IInstanceScreenProps) {
|
||||
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
|
||||
const isNewInstance = prevProps.instanceName !== this.props.instanceName;
|
||||
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
|
||||
if (isNewInstance || receivedNewEdges) {
|
||||
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
|
||||
if (isNewInstance || receivedNewEdges || receivedNewLocalGraph) {
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
}
|
||||
|
||||
private processEdgesToFindNeighbors = () => {
|
||||
const { graph, instanceName } = this.props;
|
||||
if (!graph || !instanceName) {
|
||||
const { localGraph } = this.state;
|
||||
if ((!graph && !localGraph) || !instanceName) {
|
||||
return;
|
||||
}
|
||||
this.setState({ isProcessingNeighbors: true });
|
||||
const edges = graph.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1);
|
||||
|
||||
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) {
|
||||
|
@ -144,11 +156,29 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
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 insularCallout =
|
||||
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors ? (
|
||||
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !this.state.localGraph ? (
|
||||
<Callout 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>
|
||||
</Callout>
|
||||
|
@ -158,6 +188,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
return (
|
||||
<>
|
||||
{insularCallout}
|
||||
{this.maybeRenderLocalGraph()}
|
||||
<StyledTabs>
|
||||
{this.props.instanceDetails!.description && (
|
||||
<Tab id="description" title="Description" panel={this.renderDescription()} />
|
||||
|
@ -179,6 +210,22 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
);
|
||||
};
|
||||
|
||||
private maybeRenderLocalGraph = () => {
|
||||
if (!this.state.localGraph) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<StyledGraphContainer>
|
||||
<Cytoscape
|
||||
elements={this.state.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);
|
||||
|
@ -245,21 +292,11 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
};
|
||||
|
||||
private renderNeighbors = () => {
|
||||
if (!this.props.graph || !this.props.instanceName) {
|
||||
if (!this.state.neighbors) {
|
||||
return;
|
||||
}
|
||||
const edges = this.props.graph.edges.filter(
|
||||
e => [e.data.source, e.data.target].indexOf(this.props.instanceName!) > -1
|
||||
);
|
||||
const neighbors: any[] = [];
|
||||
edges.forEach(e => {
|
||||
if (e.data.source === this.props.instanceName) {
|
||||
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
|
||||
} else {
|
||||
neighbors.push({ neighbor: e.data.source, weight: e.data.weight });
|
||||
}
|
||||
});
|
||||
const neighborRows = orderBy(neighbors, ["weight"], ["desc"]).map((neighborDetails: any, idx: number) => (
|
||||
const neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map(
|
||||
(neighborDetails: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<Link
|
||||
|
@ -272,7 +309,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
</td>
|
||||
<td>{neighborDetails.weight.toFixed(4)}</td>
|
||||
</tr>
|
||||
));
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p className={Classes.TEXT_MUTED}>
|
||||
|
@ -318,16 +356,6 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
|||
);
|
||||
};
|
||||
|
||||
private renderEmptyState = () => {
|
||||
return (
|
||||
<NonIdealState
|
||||
icon={IconNames.CIRCLE}
|
||||
title="No instance selected"
|
||||
description="Select an instance from the graph or the top-right dropdown to see its details."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
|
||||
|
||||
private renderPersonalInstanceErrorState = () => {
|
||||
|
@ -390,6 +418,7 @@ const mapStateToProps = (state: IAppState) => {
|
|||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
||||
navigateToRoot: () => dispatch(push("/"))
|
||||
});
|
||||
const InstanceScreen = connect(
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* Screen widths less than this will be treated as mobile */
|
||||
export const DESKTOP_WIDTH_THRESHOLD = 800;
|
||||
export const DESKTOP_WIDTH_THRESHOLD = 1000;
|
||||
|
||||
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
||||
export const SELECTED_NODE_COLOR = "#48AFF0";
|
||||
|
|
|
@ -16,7 +16,7 @@ import { FocusStyleManager } from "@blueprintjs/core";
|
|||
|
||||
import { routerMiddleware } from "connected-react-router";
|
||||
import { createBrowserHistory } from "history";
|
||||
import { AppRouter } from "./AppRouter";
|
||||
import AppRouter from "./AppRouter";
|
||||
import createRootReducer from "./redux/reducers";
|
||||
|
||||
// https://blueprintjs.com/docs/#core/accessibility.focus-management
|
||||
|
|
|
@ -13,6 +13,7 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
|
|||
case ActionType.REQUEST_GRAPH:
|
||||
return {
|
||||
...state,
|
||||
graph: undefined,
|
||||
isLoadingGraph: true
|
||||
};
|
||||
case ActionType.RECEIVE_GRAPH:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { createMatchSelector } from "connected-react-router";
|
||||
import fetch from "cross-fetch";
|
||||
import { IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
||||
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
||||
import { IAppState } from "./redux/types";
|
||||
|
||||
let API_ROOT = "http://localhost:4000/api/";
|
||||
|
@ -16,3 +16,5 @@ export const getFromApi = (path: string): Promise<any> => {
|
|||
};
|
||||
|
||||
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
||||
|
||||
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
|
||||
|
|
Loading…
Reference in a new issue