experimental mobile support

This commit is contained in:
Tao Bojlén 2019-07-23 16:32:43 +00:00
parent 1ff7cd7290
commit e490b30ebb
11 changed files with 161 additions and 125 deletions

View file

@ -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}%"

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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();

View file

@ -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} />

View file

@ -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(

View file

@ -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";

View file

@ -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

View file

@ -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:

View file

@ -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;