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) * have a user count (required to give the instance a size on the graph)
* the user count is > the threshold * the user count is > the threshold
* have x and y coordinates * 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()] @spec list_nodes() :: [Instance.t()]
def list_nodes() do def list_nodes(domain \\ nil) do
user_threshold = get_config(:personal_instance_threshold) user_threshold = get_config(:personal_instance_threshold)
Instance 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 not is_nil(i.x) and not is_nil(i.y) and not is_nil(i.user_count) and
i.user_count >= ^user_threshold i.user_count >= ^user_threshold
) )
|> maybe_filter_nodes_to_neighborhood(domain)
|> select([c], [:domain, :user_count, :x, :y]) |> select([c], [:domain, :user_count, :x, :y])
|> Repo.all() |> Repo.all()
end 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()] @spec list_edges() :: [Edge.t()]
def list_edges() do def list_edges(domain \\ nil) do
user_threshold = get_config(:personal_instance_threshold) user_threshold = get_config(:personal_instance_threshold)
Edge Edge
|> join(:inner, [e], i1 in Instance, on: e.source_domain == i1.domain) |> join(:inner, [e], i1 in Instance, on: e.source_domain == i1.domain)
|> join(:inner, [e], i2 in Instance, on: e.target_domain == i2.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]) |> select([e], [:id, :source_domain, :target_domain, :weight])
|> where( |> where(
[e, i1, i2], [e, i1, i2],
@ -53,6 +76,28 @@ defmodule Backend.Api do
|> Repo.all() |> Repo.all()
end 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 def search_instances(query, cursor_after \\ nil) do
ilike_query = "%#{query}%" ilike_query = "%#{query}%"

View file

@ -10,4 +10,10 @@ defmodule BackendWeb.GraphController do
edges = Api.list_edges() edges = Api.list_edges()
render(conn, "index.json", nodes: nodes, edges: edges) render(conn, "index.json", nodes: nodes, edges: edges)
end 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 end

View file

@ -9,7 +9,7 @@ defmodule BackendWeb.Router do
pipe_through(:api) pipe_through(:api)
resources("/instances", InstanceController, only: [:index, :show]) resources("/instances", InstanceController, only: [:index, :show])
resources("/graph", GraphController, only: [:index]) resources("/graph", GraphController, only: [:index, :show])
resources("/search", SearchController, only: [:index]) resources("/search", SearchController, only: [:index])
end end
end end

View file

@ -1,73 +1,21 @@
import * as React from "react"; import * as React from "react";
import { Button, Classes, Dialog } from "@blueprintjs/core"; import { Classes } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { ConnectedRouter } from "connected-react-router"; import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { Nav } from "./components/organisms/"; import { Nav } from "./components/organisms/";
import { AboutScreen, GraphScreen } from "./components/screens/"; import { AboutScreen, GraphScreen } from "./components/screens/";
import { DESKTOP_WIDTH_THRESHOLD } from "./constants";
import { history } from "./index"; import { history } from "./index";
interface IAppLocalState { const AppRouter: React.FC = () => (
mobileDialogOpen: boolean;
}
export class AppRouter extends React.Component<{}, IAppLocalState> {
constructor(props: {}) {
super(props);
this.state = { mobileDialogOpen: false };
}
public render() {
return (
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}> <div className={`${Classes.DARK} App`}>
<Nav /> <Nav />
<Route path="/about" component={AboutScreen} /> <Route path="/about" component={AboutScreen} />
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */} {/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
<GraphScreen /> <GraphScreen />
{this.renderMobileDialog()}
</div> </div>
</ConnectedRouter> </ConnectedRouter>
); );
} export default AppRouter;
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 });
};
}

View file

@ -13,8 +13,8 @@ const CytoscapeContainer = styled.div`
interface ICytoscapeProps { interface ICytoscapeProps {
currentNodeId: string | null; currentNodeId: string | null;
elements: cytoscape.ElementsDefinition; elements: cytoscape.ElementsDefinition;
navigateToInstancePath: (domain: string) => void; navigateToInstancePath?: (domain: string) => void;
navigateToRoot: () => void; navigateToRoot?: () => void;
} }
class Cytoscape extends React.Component<ICytoscapeProps> { class Cytoscape extends React.Component<ICytoscapeProps> {
private cy?: cytoscape.Core; private cy?: cytoscape.Core;
@ -105,8 +105,10 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
this.cy.nodes().on("select", e => { this.cy.nodes().on("select", e => {
const instanceId = e.target.data("id"); const instanceId = e.target.data("id");
if (instanceId && instanceId !== this.props.currentNodeId) { if (instanceId && instanceId !== this.props.currentNodeId) {
if (this.props.navigateToInstancePath) {
this.props.navigateToInstancePath(instanceId); this.props.navigateToInstancePath(instanceId);
} }
}
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood(); const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
// Reset graph visibility // Reset graph visibility
@ -130,9 +132,11 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
// Clicking on the background should also deselect // Clicking on the background should also deselect
const target = e.target; const target = e.target;
if (!target || target === this.cy || target.isEdge()) { if (!target || target === this.cy || target.isEdge()) {
if (this.props.navigateToRoot) {
// Go to the URL "/" // Go to the URL "/"
this.props.navigateToRoot(); this.props.navigateToRoot();
} }
}
}); });
this.setNodeSelection(); this.setNodeSelection();

View file

@ -8,7 +8,7 @@ import { InstanceScreen, SearchScreen } from ".";
import { INSTANCE_DOMAIN_PATH } from "../../constants"; import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions"; import { loadInstance } from "../../redux/actions";
import { IAppState } from "../../redux/types"; import { IAppState } from "../../redux/types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector, isSmallScreen } from "../../util";
import { Graph, SidebarContainer } from "../organisms/"; import { Graph, SidebarContainer } from "../organisms/";
const GraphContainer = styled.div` const GraphContainer = styled.div`
@ -50,7 +50,8 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps> {
private renderRoutes = ({ location }: RouteComponentProps) => ( private renderRoutes = ({ location }: RouteComponentProps) => (
<FullDiv> <FullDiv>
<GraphContainer> <GraphContainer>
<Graph /> {/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
{isSmallScreen || <Graph />}
<SidebarContainer> <SidebarContainer>
<Switch> <Switch>
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} /> <Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />

View file

@ -29,8 +29,8 @@ import { Link } from "react-router-dom";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types"; import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
import { ErrorState } from "../molecules/"; import { Cytoscape, ErrorState } from "../molecules/";
const InstanceScreenContainer = styled.div` const InstanceScreenContainer = styled.div`
margin-bottom: auto; margin-bottom: auto;
@ -64,6 +64,10 @@ const StyledLinkToFdNetwork = styled.div`
const StyledTabs = styled(Tabs)` const StyledTabs = styled(Tabs)`
width: 100%; width: 100%;
`; `;
const StyledGraphContainer = styled.div`
height: 50%;
margin-bottom: 10px;
`;
interface IInstanceScreenProps { interface IInstanceScreenProps {
graph?: IGraph; graph?: IGraph;
instanceName: string | null; instanceName: string | null;
@ -71,31 +75,34 @@ interface IInstanceScreenProps {
instanceDetails: IInstanceDetails | null; instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
navigateToRoot: () => void; navigateToRoot: () => void;
navigateToInstance: (domain: string) => void;
} }
interface IInstanceScreenState { interface IInstanceScreenState {
neighbors?: string[]; neighbors?: string[];
isProcessingNeighbors: boolean; 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> { class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
public constructor(props: IInstanceScreenProps) { public constructor(props: IInstanceScreenProps) {
super(props); super(props);
this.state = { isProcessingNeighbors: false }; this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
} }
public render() { public render() {
let content; let content;
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors) { if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors || this.state.isLoadingLocalGraph) {
content = this.renderLoadingState(); content = this.renderLoadingState();
} else if (!this.props.instanceDetails) { } else if (this.props.instanceLoadError || this.state.localGraphLoadError || !this.props.instanceDetails) {
return this.renderEmptyState(); return (content = <ErrorState />);
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) { } else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState(); content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) { } else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
content = this.renderRobotsTxtState(); content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") { } else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState(); content = this.renderMissingDataState();
} else if (this.props.instanceLoadError) {
return (content = <ErrorState />);
} else { } else {
content = this.renderTabs(); content = this.renderTabs();
} }
@ -115,24 +122,29 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
} }
public componentDidMount() { public componentDidMount() {
this.loadLocalGraphOnSmallScreen();
this.processEdgesToFindNeighbors(); this.processEdgesToFindNeighbors();
} }
public componentDidUpdate(prevProps: IInstanceScreenProps) { public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
const isNewInstance = prevProps.instanceName !== this.props.instanceName; const isNewInstance = prevProps.instanceName !== this.props.instanceName;
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors; 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(); this.processEdgesToFindNeighbors();
} }
} }
private processEdgesToFindNeighbors = () => { private processEdgesToFindNeighbors = () => {
const { graph, instanceName } = this.props; const { graph, instanceName } = this.props;
if (!graph || !instanceName) { const { localGraph } = this.state;
if ((!graph && !localGraph) || !instanceName) {
return; return;
} }
this.setState({ isProcessingNeighbors: true }); 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[] = []; const neighbors: any[] = [];
edges.forEach(e => { edges.forEach(e => {
if (e.data.source === instanceName) { if (e.data.source === instanceName) {
@ -144,11 +156,29 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
this.setState({ neighbors, isProcessingNeighbors: false }); 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 = () => { private renderTabs = () => {
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0; const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const insularCallout = 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"> <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> <p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
</Callout> </Callout>
@ -158,6 +188,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
return ( return (
<> <>
{insularCallout} {insularCallout}
{this.maybeRenderLocalGraph()}
<StyledTabs> <StyledTabs>
{this.props.instanceDetails!.description && ( {this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} /> <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 = () => { private shouldRenderStats = () => {
const details = this.props.instanceDetails; const details = this.props.instanceDetails;
return details && (details.version || details.userCount || details.statusCount || details.domainCount); return details && (details.version || details.userCount || details.statusCount || details.domainCount);
@ -245,21 +292,11 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
}; };
private renderNeighbors = () => { private renderNeighbors = () => {
if (!this.props.graph || !this.props.instanceName) { if (!this.state.neighbors) {
return; return;
} }
const edges = this.props.graph.edges.filter( const neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map(
e => [e.data.source, e.data.target].indexOf(this.props.instanceName!) > -1 (neighborDetails: any, idx: number) => (
);
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) => (
<tr key={idx}> <tr key={idx}>
<td> <td>
<Link <Link
@ -272,7 +309,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</td> </td>
<td>{neighborDetails.weight.toFixed(4)}</td> <td>{neighborDetails.weight.toFixed(4)}</td>
</tr> </tr>
)); )
);
return ( return (
<div> <div>
<p className={Classes.TEXT_MUTED}> <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 renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => { private renderPersonalInstanceErrorState = () => {
@ -390,6 +418,7 @@ const mapStateToProps = (state: IAppState) => {
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
navigateToRoot: () => dispatch(push("/")) navigateToRoot: () => dispatch(push("/"))
}); });
const InstanceScreen = connect( const InstanceScreen = connect(

View file

@ -1,5 +1,5 @@
/* Screen widths less than this will be treated as mobile */ /* 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 DEFAULT_NODE_COLOR = "#CED9E0";
export const SELECTED_NODE_COLOR = "#48AFF0"; export const SELECTED_NODE_COLOR = "#48AFF0";

View file

@ -16,7 +16,7 @@ import { FocusStyleManager } from "@blueprintjs/core";
import { routerMiddleware } from "connected-react-router"; import { routerMiddleware } from "connected-react-router";
import { createBrowserHistory } from "history"; import { createBrowserHistory } from "history";
import { AppRouter } from "./AppRouter"; import AppRouter from "./AppRouter";
import createRootReducer from "./redux/reducers"; import createRootReducer from "./redux/reducers";
// https://blueprintjs.com/docs/#core/accessibility.focus-management // 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: case ActionType.REQUEST_GRAPH:
return { return {
...state, ...state,
graph: undefined,
isLoadingGraph: true isLoadingGraph: true
}; };
case ActionType.RECEIVE_GRAPH: case ActionType.RECEIVE_GRAPH:

View file

@ -1,6 +1,6 @@
import { createMatchSelector } from "connected-react-router"; import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch"; 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"; import { IAppState } from "./redux/types";
let API_ROOT = "http://localhost:4000/api/"; 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 domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;