From e490b30ebb792961ffa179829a68b5a86274f1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tao=20Bojl=C3=A9n?= <2803708-taobojlen@users.noreply.gitlab.com> Date: Tue, 23 Jul 2019 16:32:43 +0000 Subject: [PATCH] experimental mobile support --- backend/lib/backend/api.ex | 49 ++++++- .../controllers/graph_controller.ex | 6 + backend/lib/backend_web/router.ex | 2 +- frontend/src/AppRouter.tsx | 76 ++--------- .../src/components/molecules/Cytoscape.tsx | 14 +- .../src/components/screens/GraphScreen.tsx | 5 +- .../src/components/screens/InstanceScreen.tsx | 125 +++++++++++------- frontend/src/constants.tsx | 2 +- frontend/src/index.tsx | 2 +- frontend/src/redux/reducers.ts | 1 + frontend/src/util.ts | 4 +- 11 files changed, 161 insertions(+), 125 deletions(-) diff --git a/backend/lib/backend/api.ex b/backend/lib/backend/api.ex index c4cff71..8e14fc6 100644 --- a/backend/lib/backend/api.ex +++ b/backend/lib/backend/api.ex @@ -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}%" diff --git a/backend/lib/backend_web/controllers/graph_controller.ex b/backend/lib/backend_web/controllers/graph_controller.ex index 106b8cc..87d531f 100644 --- a/backend/lib/backend_web/controllers/graph_controller.ex +++ b/backend/lib/backend_web/controllers/graph_controller.ex @@ -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 diff --git a/backend/lib/backend_web/router.ex b/backend/lib/backend_web/router.ex index 282cef7..fea7e2a 100644 --- a/backend/lib/backend_web/router.ex +++ b/backend/lib/backend_web/router.ex @@ -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 diff --git a/frontend/src/AppRouter.tsx b/frontend/src/AppRouter.tsx index 0b642e2..516cb02 100644 --- a/frontend/src/AppRouter.tsx +++ b/frontend/src/AppRouter.tsx @@ -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 ( - -
-
-
- ); - } - - public componentDidMount() { - if (window.innerWidth < DESKTOP_WIDTH_THRESHOLD) { - this.handleMobileDialogOpen(); - } - } - - private renderMobileDialog = () => { - return ( - -
-

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

-
-
-
-
-
-
- ); - }; - - private handleMobileDialogOpen = () => { - this.setState({ mobileDialogOpen: true }); - }; - - private handleMobileDialogClose = () => { - this.setState({ mobileDialogOpen: false }); - }; -} +const AppRouter: React.FC = () => ( + +
+
+
+); +export default AppRouter; diff --git a/frontend/src/components/molecules/Cytoscape.tsx b/frontend/src/components/molecules/Cytoscape.tsx index c518cfa..c6c76bb 100644 --- a/frontend/src/components/molecules/Cytoscape.tsx +++ b/frontend/src/components/molecules/Cytoscape.tsx @@ -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 { private cy?: cytoscape.Core; @@ -105,7 +105,9 @@ class Cytoscape extends React.Component { this.cy.nodes().on("select", e => { const instanceId = e.target.data("id"); if (instanceId && instanceId !== this.props.currentNodeId) { - this.props.navigateToInstancePath(instanceId); + if (this.props.navigateToInstancePath) { + this.props.navigateToInstancePath(instanceId); + } } const neighborhood = this.cy!.$id(instanceId).closedNeighborhood(); @@ -130,8 +132,10 @@ class Cytoscape extends React.Component { // Clicking on the background should also deselect const target = e.target; if (!target || target === this.cy || target.isEdge()) { - // Go to the URL "/" - this.props.navigateToRoot(); + if (this.props.navigateToRoot) { + // Go to the URL "/" + this.props.navigateToRoot(); + } } }); diff --git a/frontend/src/components/screens/GraphScreen.tsx b/frontend/src/components/screens/GraphScreen.tsx index 132f943..5342e34 100644 --- a/frontend/src/components/screens/GraphScreen.tsx +++ b/frontend/src/components/screens/GraphScreen.tsx @@ -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 { private renderRoutes = ({ location }: RouteComponentProps) => ( - + {/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */} + {isSmallScreen || } diff --git a/frontend/src/components/screens/InstanceScreen.tsx b/frontend/src/components/screens/InstanceScreen.tsx index b12d3c0..ae4f7fe 100644 --- a/frontend/src/components/screens/InstanceScreen.tsx +++ b/frontend/src/components/screens/InstanceScreen.tsx @@ -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 { 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 = ); } 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 = ); } else { content = this.renderTabs(); } @@ -115,24 +122,29 @@ class InstanceScreenImpl extends React.PureComponent { 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 { + 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 ? (

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

@@ -158,6 +188,7 @@ class InstanceScreenImpl extends React.PureComponent {insularCallout} + {this.maybeRenderLocalGraph()} {this.props.instanceDetails!.description && ( @@ -179,6 +210,22 @@ class InstanceScreenImpl extends React.PureComponent { + if (!this.state.localGraph) { + return; + } + return ( + + + + + ); + }; + private shouldRenderStats = () => { const details = this.props.instanceDetails; return details && (details.version || details.userCount || details.statusCount || details.domainCount); @@ -245,34 +292,25 @@ class InstanceScreenImpl extends React.PureComponent { - 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 neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map( + (neighborDetails: any, idx: number) => ( + + + + {neighborDetails.neighbor} + + + {neighborDetails.weight.toFixed(4)} + + ) ); - 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) => ( - - - - {neighborDetails.neighbor} - - - {neighborDetails.weight.toFixed(4)} - - )); return (

@@ -318,16 +356,6 @@ class InstanceScreenImpl extends React.PureComponent { - return ( - - ); - }; - private renderLoadingState = () => } />; 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( diff --git a/frontend/src/constants.tsx b/frontend/src/constants.tsx index 3a95788..c1a577c 100644 --- a/frontend/src/constants.tsx +++ b/frontend/src/constants.tsx @@ -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"; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 06351c3..5828cd7 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -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 diff --git a/frontend/src/redux/reducers.ts b/frontend/src/redux/reducers.ts index 91f121d..7131584 100644 --- a/frontend/src/redux/reducers.ts +++ b/frontend/src/redux/reducers.ts @@ -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: diff --git a/frontend/src/util.ts b/frontend/src/util.ts index bedf53f..2b4dd5b 100644 --- a/frontend/src/util.ts +++ b/frontend/src/util.ts @@ -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 => { }; export const domainMatchSelector = createMatchSelector(INSTANCE_DOMAIN_PATH); + +export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;