Update to 2.1.0

This commit is contained in:
Tao Bojlén 2019-07-24 10:31:07 +00:00
parent ff33580c99
commit 62a77988d6
53 changed files with 1497 additions and 985 deletions

View File

@ -1,6 +1,10 @@
include:
template: Dependency-Scanning.gitlab-ci.yml
dependency_scanning:
except:
- develop
test-frontend:
image: node:lts-alpine
stage: test
@ -14,9 +18,6 @@ test-frontend:
paths:
- frontend/node_modules/
- frontend/.yarn
except:
- master
- develop
only:
changes:
- frontend/*

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"elixirLS.projectDir": "backend/"
}

View File

@ -1,34 +1,70 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
### Changed
### Deprecated
### Removed
### Fixed
### Security
## [2.0.0] - 2019-07-20
## [2.1.0 - 2019-07-24]
### Added
- It's now shown in the front-end if an instance wasn't crawled because of its robots.txt.
- You can now link directly to instances at e.g. /instance/mastodon.social.
- Instance details now have a link to the corresponding fediverse.network page.
- The main graph is no longer displayed on mobile. Instead, a smaller neighborhood graph is shown.
### Changed
- You no longer have to zoom completely in to see labels.
- Label size is now dependent on the instance size.
- The instance lookup field is now front-and-center. Is also uses the backend for faster lookups. This is to improve
performance, and it lays the groundwork for full-text search over instance names and descriptions.
- The reset-graph-view button now explains what it's for when you hover over it.
### Fixed
- Previously, direct links to /about would return a 404 on Netlify's infrastructure. No longer.
## [2.0.0] - 2019-07-20
### Added
- The backend has been completely rewritten in Elixir for improved stability and performance.
- An "insularity score" was added to show the percentage of mentions to users on the same instance.
- The crawler now respects robots.txt.
### Changed
- Migrated the frontend graph from Sigma.js to Cytoscape.js.
- To improve performance, instances with no neighbors are no longer shown on the graph.
### Deprecated
- The /api/v1 endpoint no longer exists; now there's a new /api.
### Removed
### Fixed
### Security
- Spam domains can be blacklisted in the backend crawler's config.
- Add basic automated security scanning (using [Sobelow](https://github.com/andmarti1424/sc-im.git) and Gitlab's dependency scanning).
## [1.0.0] - 2018-09-01
### Added
- Initial release. The date above is inaccurate; this first version was released sometime in the fall of 2018.
- This release had a Django backend and a [Sigma.js](http://sigmajs.org/) graph.

View File

@ -70,7 +70,7 @@ You don't have to follow these instructions, but it's one way to set up a contin
* `dokku postgres:link fediversedb phoenix`
* `dokku postgres:link fediversedb gephi`
5. Update the backend configuration. In particular, change the `user_agent` in [config.exs](/backend/config/config.exs) to something descriptive.
6. Push the apps, e.g. `git push dokku@<DOMAIN>:phoenix` (from your local machine or CD pipeline)
6. Push the apps, e.g. `git push dokku@<DOMAIN>:phoenix` (note that the first push cannot be from the CD pipeline).
7. Set up SSL for the Phoenix app
* `dokku letsencrypt phoenix`
* `dokku letsencrypt:cron-job --add`

View File

@ -59,7 +59,7 @@ config :backend, Backend.Repo,
config :backend, :crawler,
status_age_limit_days: 28,
status_count_limit: 100,
personal_instance_threshold: 1,
personal_instance_threshold: 5,
crawl_interval_mins: 1,
crawl_workers: 10,
blacklist: [
@ -68,6 +68,6 @@ config :backend, :crawler,
config :backend, Backend.Scheduler,
jobs: [
# Every 15 minutes
{"*/15 * * * *", {Backend.Scheduler, :prune_crawls, [12, "hour"]}}
# Every 5 minutes
{"*/5 * * * *", {Backend.Scheduler, :prune_crawls, [12, "month"]}}
]

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],
@ -52,4 +75,38 @@ 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}%"
%{entries: instances, metadata: metadata} =
Instance
|> where([i], ilike(i.domain, ^ilike_query))
|> order_by(asc: :id)
|> Repo.paginate(after: cursor_after, cursor_fields: [:id], limit: 50)
%{instances: instances, next: metadata.after}
end
end

View File

@ -3,6 +3,8 @@ defmodule Backend.Repo do
otp_app: :backend,
adapter: Ecto.Adapters.Postgres
use Paginator
def init(_type, config) do
{:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))}
end

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

@ -13,7 +13,7 @@ defmodule BackendWeb.InstanceController do
def show(conn, %{"id" => domain}) do
instance = Api.get_instance!(domain)
last_crawl = get_last_successful_crawl(domain)
last_crawl = get_last_crawl(domain)
render(conn, "show.json", instance: instance, crawl: last_crawl)
end

View File

@ -0,0 +1,13 @@
defmodule BackendWeb.SearchController do
use BackendWeb, :controller
alias Backend.Api
action_fallback(BackendWeb.FallbackController)
def index(conn, params) do
query = Map.get(params, "query")
cursor_after = Map.get(params, "after", nil)
%{instances: instances, next: next} = Api.search_instances(query, cursor_after)
render(conn, "index.json", instances: instances, next: next)
end
end

View File

@ -2,13 +2,14 @@ defmodule BackendWeb.Router do
use BackendWeb, :router
pipeline :api do
plug :accepts, ["json"]
plug(:accepts, ["json"])
end
scope "/api", BackendWeb do
pipe_through :api
pipe_through(:api)
resources "/instances", InstanceController, only: [:index, :show]
resources "/graph", GraphController, only: [:index]
resources("/instances", InstanceController, only: [:index, :show])
resources("/graph", GraphController, only: [:index, :show])
resources("/search", SearchController, only: [:index])
end
end

View File

@ -0,0 +1,29 @@
defmodule BackendWeb.SearchView do
use BackendWeb, :view
alias BackendWeb.SearchView
import Backend.Util
def render("index.json", %{instances: instances, next: next}) do
%{
results: render_many(instances, SearchView, "instance.json", as: :instance),
next: next
}
end
def render("instance.json", %{instance: instance}) do
threshold = get_config(:personal_instance_threshold)
description =
if instance.user_count != nil and instance.user_count < threshold do
nil
else
instance.description
end
%{
name: instance.domain,
description: description,
userCount: instance.user_count
}
end
end

View File

@ -4,7 +4,7 @@ defmodule Backend.MixProject do
def project do
[
app: :backend,
version: "2.0.0",
version: "2.1.0",
elixir: "~> 1.5",
elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
@ -47,7 +47,8 @@ defmodule Backend.MixProject do
{:quantum, "~> 2.3"},
{:corsica, "~> 1.1.2"},
{:sobelow, "~> 0.8", only: :dev},
{:gollum, "~> 0.3.2"}
{:gollum, "~> 0.3.2"},
{:paginator, "~> 0.6.0"}
]
end

View File

@ -25,6 +25,7 @@
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"paginator": {:hex, :paginator, "0.6.0", "bc2c01abdd98281ff39b6a7439cf540091122a7927bdaabc167c61d4508f9cbb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},

View File

@ -3,6 +3,7 @@
The React frontend for [fediverse.space](https://fediverse.space). Written in Typescript.
- Set the environment variable `REACT_APP_STAGING=true` when building to use the staging backend.
- React components are organized into atoms, molecules, organisms, and screens according to [Atomic Design](http://bradfrost.com/blog/post/atomic-web-design/).
# Default README

View File

@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "2.0.0",
"version": "2.1.0",
"private": true,
"scripts": {
"start": "NODE_ENV=development react-scripts start",
@ -32,15 +32,17 @@
"@blueprintjs/icons": "^3.9.1",
"@blueprintjs/select": "^3.9.0",
"classnames": "^2.2.6",
"connected-react-router": "^6.5.2",
"cross-fetch": "^3.0.4",
"cytoscape": "^3.8.1",
"cytoscape-popper": "^1.0.4",
"lodash": "^4.17.14",
"inflection": "^1.12.0",
"lodash": "^4.17.15",
"moment": "^2.22.2",
"normalize.css": "^8.0.0",
"numeral": "^2.0.6",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react": "^16.8.0",
"react-dom": "^16.8.0",
"react-redux": "^7.1.0",
"react-router-dom": "^5.0.1",
"react-scripts": "^3.0.1",
@ -55,20 +57,22 @@
"devDependencies": {
"@blueprintjs/tslint-config": "^1.8.1",
"@types/classnames": "^2.2.9",
"@types/cytoscape": "^3.4.3",
"@types/cytoscape": "^3.8.0",
"@types/inflection": "^1.5.28",
"@types/jest": "^24.0.15",
"@types/lodash": "^4.14.136",
"@types/node": "^12.6.2",
"@types/node": "^12.6.8",
"@types/numeral": "^0.0.25",
"@types/react": "^16.8.23",
"@types/react-dom": "^16.8.4",
"@types/react-redux": "^7.1.1",
"@types/react-router-dom": "^4.3.4",
"@types/react-virtualized": "^9.21.2",
"@types/react-virtualized": "^9.21.3",
"@types/sanitize-html": "^1.20.1",
"@types/styled-components": "4.1.18",
"husky": "^3.0.0",
"husky": "^3.0.1",
"lint-staged": "^9.2.0",
"react-axe": "^3.2.0",
"tslint": "^5.18.0",
"tslint-config-security": "^1.16.0",
"tslint-eslint-rules": "^5.4.0",

View File

@ -5,6 +5,24 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!-- For search engines -->
<meta name="description" content="A tool to visualize decentralized social networks." />
<!-- Open Graph -->
<meta property="og:site_name" content="fediverse.space" />
<meta property="og:description" content="" />
<meta property="og:image" content="%PUBLIC_URL%/preview.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="914" />
<meta property="og:image:height" content="679" />
<meta property="og:image:alt" content="A screenshot of fediverse.space. Shows a graph of fediverse instances." />
<!-- Twitter cards -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="fediverse.space">
<meta name="twitter:description" content="A tool to visualize decentralized social networks.">
<meta name="twitter:image" content="%PUBLIC_URL%/preview.png">
<meta name="twitter:image:alt" content="A screenshot of fediverse.space. Shows a graph of fediverse instances." />
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
@ -30,4 +48,4 @@
<div id="root"></div>
</body>
</html>
</html>

BIN
frontend/public/preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

View File

@ -1,71 +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 { BrowserRouter, Route } from "react-router-dom";
import { Nav } from "./components/Nav";
import { AboutScreen } from "./components/screens/AboutScreen";
import { GraphScreen } from "./components/screens/GraphScreen";
import { DESKTOP_WIDTH_THRESHOLD } from "./constants";
import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom";
import { Nav } from "./components/organisms/";
import { AboutScreen, GraphScreen } from "./components/screens/";
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 (
<BrowserRouter>
<div className={`${Classes.DARK} App`}>
<Nav />
<Route exact={true} path="/" component={GraphScreen} />
<Route path="/about" component={AboutScreen} />
{this.renderMobileDialog()}
</div>
</BrowserRouter>
);
}
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 });
};
}
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 />
</div>
</ConnectedRouter>
);
export default AppRouter;

View File

@ -1,5 +0,0 @@
import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
export const ErrorState: React.SFC = () => <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} />;

View File

@ -1,12 +0,0 @@
import { Button } from "@blueprintjs/core";
import * as React from "react";
import FloatingCard from "./FloatingCard";
interface IFloatingResetButtonProps {
onClick?: () => any;
}
export const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
<FloatingCard>
<Button icon="compass" onClick={onClick} />
</FloatingCard>
);

View File

@ -1,101 +0,0 @@
import { get } from "lodash";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IGraph } from "../redux/types";
import Cytoscape from "./Cytoscape";
import { ErrorState } from "./ErrorState";
import { FloatingResetButton } from "./FloatingResetButton";
interface IGraphProps {
graph?: IGraph;
currentInstanceName: string | null;
selectAndLoadInstance: (name: string) => void;
}
class GraphImpl extends React.Component<IGraphProps> {
private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: IGraphProps) {
super(props);
this.cytoscapeComponent = React.createRef();
}
public render() {
if (!this.props.graph) {
return <ErrorState />;
}
return (
<div>
<Cytoscape
elements={this.props.graph}
onInstanceSelect={this.onInstanceSelect}
onInstanceDeselect={this.onInstanceDeselect}
ref={this.cytoscapeComponent}
/>
<FloatingResetButton onClick={this.resetGraphPosition} />
</div>
);
}
public componentDidUpdate(prevProps: IGraphProps) {
const { currentInstanceName } = this.props;
if (prevProps.currentInstanceName !== currentInstanceName) {
const cy = this.getCytoscape();
cy.$id(prevProps.currentInstanceName).unselect();
if (currentInstanceName) {
// Select instance
cy.$id(`${currentInstanceName}`).select();
// Center it
const selected = cy.$id(currentInstanceName);
cy.center(selected);
}
}
}
private resetGraphPosition = () => {
const cy = this.getCytoscape();
const { currentInstanceName } = this.props;
if (currentInstanceName) {
cy.zoom({
level: 0.2,
position: cy.$id(currentInstanceName).position()
});
} else {
cy.zoom({
level: 0.2,
position: { x: 0, y: 0 }
});
}
};
private onInstanceSelect = (domain: string) => {
this.props.selectAndLoadInstance(domain);
};
private onInstanceDeselect = () => {
this.props.selectAndLoadInstance("");
};
private getCytoscape = () => {
const cy = get(this.cytoscapeComponent, "current.cy");
if (!cy) {
throw new Error("Expected cytoscape component but did not find one.");
}
return cy;
};
}
const mapStateToProps = (state: IAppState) => ({
currentInstanceName: state.currentInstance.currentInstanceName,
graph: state.data.graph
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph;

View File

@ -1,85 +0,0 @@
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { Button, MenuItem } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { IItemRendererProps, ItemPredicate, Select } from "@blueprintjs/select";
import { RouteComponentProps, withRouter } from "react-router";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IInstance } from "../redux/types";
interface IInstanceSearchProps extends RouteComponentProps {
currentInstanceName: string | null;
instances?: IInstance[];
selectAndLoadInstance: (instanceName: string) => void;
}
const InstanceSelect = Select.ofType<IInstance>();
class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
public render() {
return (
<InstanceSelect
items={this.props.instances || []}
itemRenderer={this.itemRenderer}
onItemSelect={this.onItemSelect}
itemPredicate={this.itemPredicate}
disabled={!this.props.instances || this.props.location.pathname !== "/"}
initialContent={this.renderInitialContent()}
noResults={this.renderNoResults()}
popoverProps={{ popoverClassName: "fediverse-instance-search-popover" }}
>
<Button
icon={IconNames.SELECTION}
rightIcon={IconNames.CARET_DOWN}
text={this.props.currentInstanceName || "Select an instance"}
disabled={!this.props.instances}
/>
</InstanceSelect>
);
}
private renderInitialContent = () => {
return <MenuItem disabled={true} text={"Start typing"} />;
};
private renderNoResults = () => {
return <MenuItem disabled={true} text={"Keep typing"} />;
};
private itemRenderer = (item: IInstance, itemProps: IItemRendererProps) => {
if (!itemProps.modifiers.matchesPredicate) {
return null;
}
return (
<MenuItem text={item.name} key={item.name} active={itemProps.modifiers.active} onClick={itemProps.handleClick} />
);
};
private itemPredicate: ItemPredicate<IInstance> = (query, item, index) => {
if (!item.name || query.length < 4) {
return false;
}
return item.name.toLowerCase().indexOf(query.toLowerCase()) >= 0;
};
private onItemSelect = (item: IInstance, event?: React.SyntheticEvent<HTMLElement>) => {
this.props.selectAndLoadInstance(item.name);
};
}
const mapStateToProps = (state: IAppState) => ({
currentInstanceName: state.currentInstance.currentInstanceName,
instances: state.data.instances
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
export const InstanceSearch = withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(InstanceSearchImpl)
);

View File

@ -1,41 +0,0 @@
import * as React from "react";
import { Alignment, Button, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { InstanceSearch } from "./InstanceSearch";
interface INavState {
aboutIsOpen: boolean;
}
export class Nav extends React.Component<{}, INavState> {
constructor(props: any) {
super(props);
this.state = { aboutIsOpen: false };
}
public render() {
const StyledLink = styled(Link)`
color: white !important;
`;
return (
<Navbar fixedToTop={true}>
<Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>fediverse.space</Navbar.Heading>
<Navbar.Divider />
<StyledLink to="/">
<Button icon={IconNames.GLOBE_NETWORK} text="Home" minimal={true} />
</StyledLink>
<StyledLink to="/about">
<Button icon={IconNames.INFO_SIGN} text="About" minimal={true} />
</StyledLink>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<InstanceSearch />
</Navbar.Group>
</Navbar>
);
}
}

View File

@ -1,7 +0,0 @@
import styled from "styled-components";
export const Page = styled.div`
max-width: 800px;
margin: auto;
padding: 2em;
`;

View File

@ -1,405 +0,0 @@
import { orderBy } from "lodash";
import moment from "moment";
import * as numeral from "numeral";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import sanitize from "sanitize-html";
import {
AnchorButton,
Button,
Callout,
Card,
Classes,
Code,
Divider,
Elevation,
H2,
H4,
HTMLTable,
Icon,
NonIdealState,
Position,
Tab,
Tabs,
Tooltip
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IGraph, IInstanceDetails } from "../redux/types";
import FullDiv from "./atoms/FullDiv";
import { ErrorState } from "./ErrorState";
interface ISidebarProps {
graph?: IGraph;
instanceName: string | null;
instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean;
selectAndLoadInstance: (instanceName: string) => void;
}
interface ISidebarState {
isOpen: boolean;
neighbors?: string[];
isProcessingNeighbors: boolean;
}
class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
constructor(props: ISidebarProps) {
super(props);
const isOpen = window.innerWidth >= 900 ? true : false;
this.state = { isOpen, isProcessingNeighbors: false };
}
public componentDidMount() {
this.processEdgesToFindNeighbors();
}
public componentDidUpdate(prevProps: ISidebarProps, prevState: ISidebarState) {
if (prevProps.instanceName !== this.props.instanceName) {
this.processEdgesToFindNeighbors();
}
}
public render() {
const closedClass = this.state.isOpen ? "" : " closed";
const buttonIcon = this.state.isOpen ? IconNames.DOUBLE_CHEVRON_RIGHT : IconNames.DOUBLE_CHEVRON_LEFT;
return (
<div>
<Button
onClick={this.handleToggle}
large={true}
icon={buttonIcon}
className={"fediverse-sidebar-toggle-button" + closedClass}
minimal={true}
/>
<Card className={"fediverse-sidebar" + closedClass} elevation={Elevation.TWO}>
{this.renderSidebarContents()}
</Card>
</div>
);
}
private handleToggle = () => {
this.setState({ isOpen: !this.state.isOpen });
};
private processEdgesToFindNeighbors = () => {
const { graph, instanceName } = this.props;
if (!graph || !instanceName) {
return;
}
this.setState({ isProcessingNeighbors: true });
const edges = graph.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 renderSidebarContents = () => {
let content;
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors) {
content = this.renderLoadingState();
} else if (!this.props.instanceDetails) {
return this.renderEmptyState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState();
} else if (this.props.instanceLoadError) {
return (content = <ErrorState />);
} else {
content = this.renderTabs();
}
return (
<FullDiv>
{this.renderHeading()}
{content}
</FullDiv>
);
};
private renderTabs = () => {
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const insularCallout = hasNeighbors ? (
undefined
) : (
<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>
);
return (
<div>
{insularCallout}
<Tabs>
{this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} />
)}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
</Tabs>
</div>
);
};
private shouldRenderStats = () => {
const details = this.props.instanceDetails;
return details && (details.version || details.userCount || details.statusCount || details.domainCount);
};
private renderHeading = () => {
let content: JSX.Element;
if (!this.props.instanceName) {
return;
} else {
content = (
<span>
{this.props.instanceName + " "}
<Tooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</Tooltip>
</span>
);
}
return (
<div>
<H2>{content}</H2>
<Divider />
</div>
);
};
private renderDescription = () => {
const description = this.props.instanceDetails!.description;
if (!description) {
return;
}
return <p className={Classes.RUNNING_TEXT} dangerouslySetInnerHTML={{ __html: sanitize(description) }} />;
};
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 (
<div>
<HTMLTable small={true} striped={true} className="fediverse-sidebar-table">
<tbody>
<tr>
<td>Version</td>
<td>{<Code>{version}</Code> || "Unknown"}</td>
</tr>
<tr>
<td>Users</td>
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>Statuses</td>
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>
Insularity{" "}
<Tooltip
content={
<span>
The percentage of mentions that are directed
<br />
toward users on the same instance.
</span>
}
position={Position.TOP}
className={Classes.DARK}
>
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
</Tooltip>
</td>
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
</tr>
<tr>
<td>Known peers</td>
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr>
</tbody>
</HTMLTable>
</div>
);
};
private renderNeighbors = () => {
if (!this.props.graph || !this.props.instanceName) {
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) => (
<tr key={idx}>
<td>
<AnchorButton minimal={true} onClick={this.selectInstance}>
{neighborDetails.neighbor}
</AnchorButton>
</td>
<td>{neighborDetails.weight.toFixed(4)}</td>
</tr>
));
return (
<div>
<p className={Classes.TEXT_MUTED}>
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.
</p>
<HTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<thead>
<tr>
<th>Instance</th>
<th>Mention ratio</th>
</tr>
</thead>
<tbody>{neighborRows}</tbody>
</HTMLTable>
</div>
);
};
private renderPeers = () => {
const peers = this.props.instanceDetails!.peers;
if (!peers || peers.length === 0) {
return;
}
const peerRows = peers.map(instance => (
<tr key={instance.name} onClick={this.selectInstance}>
<td>
<AnchorButton minimal={true} onClick={this.selectInstance}>
{instance.name}
</AnchorButton>
</td>
</tr>
));
return (
<div>
<p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about.
</p>
<HTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody>
</HTMLTable>
</div>
);
};
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 = () => {
return (
<div>
<H4>
<span className={Classes.SKELETON}>Description</span>
</H4>
<p className={Classes.SKELETON}>
Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt. Cupiditate sit
voluptates quia nulla et saepe id suscipit. Voluptas sed rerum placeat consectetur pariatur necessitatibus
tempora. Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt.
Cupiditate sit voluptates quia nulla et saepe id suscipit. Voluptas sed rerum placeat consectetur pariatur
necessitatibus tempora.
</p>
<H4>
<span className={Classes.SKELETON}>Version</span>
</H4>
<p className={Classes.SKELETON}>Eaque rerum sequi unde omnis voluptatibus non quia fugit.</p>
<H4>
<span className={Classes.SKELETON}>Stats</span>
</H4>
<p className={Classes.SKELETON}>
Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt. Cupiditate sit
voluptates quia nulla et saepe id suscipit. Eaque rerum sequi unde omnis voluptatibus non quia fugit.
Dignissimos asperiores aut incidunt. Cupiditate sit voluptates quia nulla et saepe id suscipit.
</p>
</div>
);
};
private renderPersonalInstanceErrorState = () => {
return (
<NonIdealState
icon={IconNames.BLOCKED_PERSON}
title="No data"
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
action={
<AnchorButton icon={IconNames.CONFIRM} href="https://cursed.technology/@fediversespace" target="_blank">
Message @fediversespace to opt in
</AnchorButton>
}
/>
);
};
private renderMissingDataState = () => {
return (
<FullDiv>
<NonIdealState
icon={IconNames.ERROR}
title="No data"
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
/>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
{this.props.instanceDetails && this.props.instanceDetails.status}
</span>
</FullDiv>
);
};
private openInstanceLink = () => {
window.open("https://" + this.props.instanceName, "_blank");
};
private selectInstance = (e: any) => {
this.props.selectAndLoadInstance(e.target.innerText);
};
}
const mapStateToProps = (state: IAppState) => ({
graph: state.data.graph,
instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error,
instanceName: state.currentInstance.currentInstanceName,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
export const Sidebar = connect(
mapStateToProps,
mapDispatchToProps
)(SidebarImpl);

View File

@ -1,6 +0,0 @@
import styled from "styled-components";
export default styled.div`
width: 100%;
height: 100%;
`;

View File

@ -0,0 +1,26 @@
import * as React from "react";
import styled from "styled-components";
const Backdrop = styled.div`
position: absolute;
top: 50px;
bottom: 0;
left: 0;
right: 0;
background-color: #293742;
z-index: 100;
`;
const Container = styled.div`
max-width: 800px;
margin: auto;
padding: 2em;
`;
const Page: React.FC = ({ children }) => (
<Backdrop>
<Container>{children}</Container>
</Backdrop>
);
export default Page;

View File

@ -0,0 +1,2 @@
export { default as Page } from "./Page";
export { default as FloatingCard } from "./FloatingCard";

View File

@ -1,30 +1,33 @@
import cytoscape from "cytoscape";
import popper from "cytoscape-popper";
import * as React from "react";
import ReactDOM from "react-dom";
import styled from "styled-components";
import tippy, { Instance } from "tippy.js";
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../constants";
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../../constants";
const EntireWindowDiv = styled.div`
position: absolute;
top: 50px;
bottom: 0;
right: 0;
left: 0;
const CytoscapeContainer = styled.div`
width: 100%;
height: 100%;
flex: 1;
`;
interface ICytoscapeProps {
currentNodeId: string | null;
elements: cytoscape.ElementsDefinition;
onInstanceSelect: (domain: string) => void;
onInstanceDeselect: () => void;
navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void;
}
class Cytoscape extends React.Component<ICytoscapeProps> {
public cy?: cytoscape.Core;
private cy?: cytoscape.Core;
public shouldComponentUpdate(prevProps: ICytoscapeProps) {
// We only want to update this component if the current instance selection changes.
// We know that the `elements` prop will never change so we skip the expensive computations here.
return prevProps.currentNodeId !== this.props.currentNodeId;
}
public componentDidMount() {
const container = ReactDOM.findDOMNode(this);
cytoscape.use(popper as any);
this.cy = cytoscape({
autoungrabify: true,
container: container as any,
@ -87,14 +90,14 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
.selector("node[label]")
.style({
color: DEFAULT_NODE_COLOR,
"font-size": 50,
"font-size": "mapData(size, 1, 6, 10, 100)",
"min-zoomed-font-size": 16
})
.selector(".hidden")
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected
.style({
display: "none"
})
.selector(".thickEdge")
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
.style({
width: 2
})
@ -102,8 +105,10 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
this.cy.nodes().on("select", e => {
const instanceId = e.target.data("id");
if (instanceId) {
this.props.onInstanceSelect(instanceId);
if (instanceId && instanceId !== this.props.currentNodeId) {
if (this.props.navigateToInstancePath) {
this.props.navigateToInstancePath(instanceId);
}
}
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
@ -119,7 +124,6 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
});
});
this.cy.nodes().on("unselect", e => {
this.props.onInstanceDeselect();
this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge");
@ -128,14 +132,19 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
this.cy.on("click", e => {
// Clicking on the background should also deselect
const target = e.target;
if (!target) {
this.props.onInstanceDeselect();
if (!target || target === this.cy || target.isEdge()) {
if (this.props.navigateToRoot) {
// Go to the URL "/"
this.props.navigateToRoot();
}
}
this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge");
});
});
this.setNodeSelection();
}
public componentDidUpdate(prevProps: ICytoscapeProps) {
this.setNodeSelection(prevProps.currentNodeId);
}
public componentWillUnmount() {
@ -145,8 +154,47 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
}
public render() {
return <EntireWindowDiv />;
return <CytoscapeContainer />;
}
public resetGraphPosition() {
if (!this.cy) {
throw new Error("Expected cytoscape, but there wasn't one!");
}
const { currentNodeId } = this.props;
if (currentNodeId) {
this.cy.zoom({
level: 0.2,
position: this.cy.$id(currentNodeId).position()
});
} else {
this.cy.zoom({
level: 0.2,
position: { x: 0, y: 0 }
});
}
}
/**
* Updates cytoscape's internal state to match our props.
*/
private setNodeSelection = (prevNodeId?: string | null) => {
if (!this.cy) {
throw new Error("Expected cytoscape, but there wasn't one!");
}
if (prevNodeId) {
this.cy.$id(prevNodeId).unselect();
}
const { currentNodeId } = this.props;
if (currentNodeId) {
// Select instance
this.cy.$id(currentNodeId).select();
// Center it
const selected = this.cy.$id(currentNodeId);
this.cy.center(selected);
}
};
}
export default Cytoscape;

View File

@ -0,0 +1,7 @@
import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
const ErrorState: React.SFC = () => <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} />;
export default ErrorState;

View File

@ -0,0 +1,13 @@
import { Button } from "@blueprintjs/core";
import * as React from "react";
import { FloatingCard } from "../atoms/";
interface IFloatingResetButtonProps {
onClick?: () => any;
}
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
<FloatingCard>
<Button icon="compass" title="Reset graph view" onClick={onClick} />
</FloatingCard>
);
export default FloatingResetButton;

View File

@ -0,0 +1,48 @@
import { Card, Classes, Elevation, H4 } from "@blueprintjs/core";
import inflection from "inflection";
import * as numeral from "numeral";
import React from "react";
import sanitize from "sanitize-html";
import styled from "styled-components";
import { ISearchResultInstance } from "../../redux/types";
const StyledCard = styled(Card)`
width: 80%;
margin: 1em auto;
background-color: #394b59 !important;
text-align: left;
`;
const StyledH4 = styled(H4)`
margin-bottom: 5px;
`;
const StyledUserCount = styled.div`
margin: 0;
`;
const StyledDescription = styled.div`
margin-top: 10px;
`;
interface ISearchResultProps {
result: ISearchResultInstance;
onClick: () => void;
}
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
let shortenedDescription;
if (result.description) {
shortenedDescription = result.description && sanitize(result.description);
if (shortenedDescription.length > 100) {
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
}
}
return (
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
<StyledH4>{result.name}</StyledH4>
{result.userCount && (
<StyledUserCount className={Classes.TEXT_MUTED}>
{numeral.default(result.userCount).format("0,0")} {inflection.inflect("people", result.userCount, "person")}
</StyledUserCount>
)}
{shortenedDescription && <StyledDescription dangerouslySetInnerHTML={{ __html: shortenedDescription }} />}
</StyledCard>
);
};
export default SearchResult;

View File

@ -0,0 +1,4 @@
export { default as Cytoscape } from "./Cytoscape";
export { default as ErrorState } from "./ErrorState";
export { default as FloatingResetButton } from "./FloatingResetButton";
export { default as SearchResult } from "./SearchResult";

View File

@ -0,0 +1,98 @@
import * as React from "react";
import { connect } from "react-redux";
import { NonIdealState, Spinner } from "@blueprintjs/core";
import { push } from "connected-react-router";
import { Dispatch } from "redux";
import styled from "styled-components";
import { fetchGraph } from "../../redux/actions";
import { IAppState, IGraph } from "../../redux/types";
import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
const GraphDiv = styled.div`
flex: 2;
`;
interface IGraphProps {
currentInstanceName: string | null;
fetchGraph: () => void;
graph?: IGraph;
graphLoadError: boolean;
isLoadingGraph: boolean;
navigate: (path: string) => void;
}
class GraphImpl extends React.Component<IGraphProps> {
private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: IGraphProps) {
super(props);
this.cytoscapeComponent = React.createRef();
}
public componentDidMount() {
this.loadGraph();
}
public render() {
let content;
if (this.props.isLoadingGraph) {
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
} else if (this.props.graphLoadError || !this.props.graph) {
content = <ErrorState />;
} else {
content = (
<>
<Cytoscape
currentNodeId={this.props.currentInstanceName}
elements={this.props.graph}
navigateToInstancePath={this.navigateToInstancePath}
navigateToRoot={this.navigateToRoot}
ref={this.cytoscapeComponent}
/>
<FloatingResetButton onClick={this.resetGraphPosition} />
</>
);
}
return <GraphDiv>{content}</GraphDiv>;
}
private loadGraph = () => {
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
this.props.fetchGraph();
}
};
private resetGraphPosition = () => {
if (this.cytoscapeComponent.current) {
this.cytoscapeComponent.current.resetGraphPosition();
}
};
private navigateToInstancePath = (domain: string) => {
this.props.navigate(`/instance/${domain}`);
};
private navigateToRoot = () => {
this.props.navigate("/");
};
}
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
graph: state.data.graph,
graphLoadError: state.data.error,
isLoadingGraph: state.data.isLoadingGraph
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
navigate: (path: string) => dispatch(push(path))
});
const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph;

View File

@ -0,0 +1,52 @@
import * as React from "react";
import { Alignment, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom";
import { IInstanceDomainPath } from "../../constants";
interface INavState {
aboutIsOpen: boolean;
}
const linkIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => {
return location.pathname === "/" || location.pathname.startsWith("/instance/");
};
class Nav extends React.Component<{}, INavState> {
constructor(props: any) {
super(props);
this.state = { aboutIsOpen: false };
}
public render() {
return (
<Navbar fixedToTop={true}>
<Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>fediverse.space</Navbar.Heading>
<Navbar.Divider />
<NavLink
to="/"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
activeClassName={Classes.INTENT_PRIMARY}
isActive={linkIsActive as any}
>
Home
</NavLink>
<NavLink
to="/about"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
activeClassName={Classes.INTENT_PRIMARY}
exact={true}
>
About
</NavLink>
</Navbar.Group>
</Navbar>
);
}
}
export default Nav;

View File

@ -0,0 +1,29 @@
import { Card, Elevation } from "@blueprintjs/core";
import React from "react";
import styled from "styled-components";
const RightDiv = styled.div`
align-self: right;
background-color: grey;
flex: 1;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
overflow-x: hidden;
`;
const StyledCard = styled(Card)`
min-height: 100%;
width: 100%;
padding: 20px 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
`;
const SidebarContainer: React.FC = ({ children }) => {
return (
<RightDiv>
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
</RightDiv>
);
};
export default SidebarContainer;

View File

@ -0,0 +1,3 @@
export { default as Graph } from "./Graph";
export { default as Nav } from "./Nav";
export { default as SidebarContainer } from "./SidebarContainer";

View File

@ -1,8 +1,8 @@
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react";
import { Page } from "../Page";
import { Page } from "../atoms/";
export const AboutScreen: React.FC = () => (
const AboutScreen: React.FC = () => (
<Page>
<H1>About</H1>
<p className={Classes.RUNNING_TEXT}>
@ -85,3 +85,4 @@ export const AboutScreen: React.FC = () => (
</p>
</Page>
);
export default AboutScreen;

View File

@ -1,79 +1,87 @@
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { NonIdealState, Spinner } from "@blueprintjs/core";
import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { InstanceScreen, SearchScreen } from ".";
import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions";
import { IAppState } from "../../redux/types";
import { domainMatchSelector, isSmallScreen } from "../../util";
import { Graph, SidebarContainer } from "../organisms/";
import { fetchGraph, fetchInstances } from "../../redux/actions";
import { IAppState, IGraph, IInstance } from "../../redux/types";
import { ErrorState } from "../ErrorState";
import Graph from "../Graph";
import { Sidebar } from "../Sidebar";
const GraphContainer = styled.div`
display: flex;
height: 100%;
width: 100%;
`;
const FullDiv = styled.div`
position: absolute;
top: 50px;
bottom: 0;
left: 0;
right: 0;
`;
interface IGraphScreenProps {
graph?: IGraph;
instances?: IInstance[];
isLoadingGraph: boolean;
isLoadingInstances: boolean;
interface IGraphScreenProps extends RouteComponentProps {
currentInstanceName: string | null;
pathname: string;
graphLoadError: boolean;
fetchInstances: () => void;
fetchGraph: () => void;
loadInstance: (domain: string | null) => void;
}
/**
* This component takes care of loading or deselecting the current instance when the URL path changes.
* It also handles changing and animating the screen shown in the sidebar.
*/
class GraphScreenImpl extends React.Component<IGraphScreenProps> {
public render() {
let body = <div />;
if (this.props.isLoadingInstances || this.props.isLoadingGraph) {
body = this.loadingState("Loading...");
} else {
body = this.graphState();
}
return <div>{body}</div>;
return <Route render={this.renderRoutes} />;
}
public componentDidMount() {
this.load();
this.loadCurrentInstance();
}
public componentDidUpdate() {
this.load();
public componentDidUpdate(prevProps: IGraphScreenProps) {
this.loadCurrentInstance(prevProps.currentInstanceName);
}
private load = () => {
if (!this.props.instances && !this.props.isLoadingInstances && !this.props.graphLoadError) {
this.props.fetchInstances();
}
if (!this.props.graph && !this.props.isLoadingGraph && !this.props.graphLoadError) {
this.props.fetchGraph();
}
};
private renderRoutes = ({ location }: RouteComponentProps) => (
<FullDiv>
<GraphContainer>
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
{isSmallScreen || <Graph />}
<SidebarContainer>
<Switch>
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
<Route exact={true} path="/" component={SearchScreen} />
</Switch>
</SidebarContainer>
</GraphContainer>
</FullDiv>
);
private graphState = () => {
const content = this.props.graphLoadError ? <ErrorState /> : <Graph />;
return (
<div>
<Sidebar />
{content}
</div>
);
};
private loadingState = (title?: string) => {
return <NonIdealState icon={<Spinner />} title={title || "Loading..."} />;
private loadCurrentInstance = (prevInstanceName?: string | null) => {
if (prevInstanceName !== this.props.currentInstanceName) {
this.props.loadInstance(this.props.currentInstanceName);
}
};
}
const mapStateToProps = (state: IAppState) => ({
graph: state.data.graph,
graphLoadError: state.data.error,
instances: state.data.instances,
isLoadingGraph: state.data.isLoadingGraph,
isLoadingInstances: state.data.isLoadingInstances
});
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
graphLoadError: state.data.error,
pathname: state.router.location.pathname
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
fetchInstances: () => dispatch(fetchInstances() as any)
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
});
export const GraphScreen = connect(
const GraphScreen = connect(
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);
export default withRouter(GraphScreen);

View File

@ -0,0 +1,441 @@
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<IInstanceScreenProps, IInstanceScreenState> {
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 = <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 {
content = this.renderTabs();
}
return (
<InstanceScreenContainer>
<HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer>
<Divider />
{content}
</InstanceScreenContainer>
);
}
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 ? (
<StyledCallout 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>
</StyledCallout>
) : (
undefined
);
return (
<>
{insularCallout}
{this.maybeRenderLocalGraph()}
<StyledTabs>
{this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} />
)}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
</StyledTabs>
<StyledLinkToFdNetwork>
<AnchorButton
href={`https://fediverse.network/${this.props.instanceName}`}
minimal={true}
rightIcon={IconNames.SHARE}
target="_blank"
text="See more statistics at fediverse.network"
/>
</StyledLinkToFdNetwork>
</>
);
};
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 (
<StyledGraphContainer>
<Cytoscape
elements={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);
};
private renderDescription = () => {
const description = this.props.instanceDetails!.description;
if (!description) {
return;
}
return <p className={Classes.RUNNING_TEXT} dangerouslySetInnerHTML={{ __html: sanitize(description) }} />;
};
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 (
<StyledHTMLTable small={true} striped={true}>
<tbody>
<tr>
<td>Version</td>
<td>{<Code>{version}</Code> || "Unknown"}</td>
</tr>
<tr>
<td>Users</td>
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>Statuses</td>
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>
Insularity{" "}
<Tooltip
content={
<span>
The percentage of mentions that are directed
<br />
toward users on the same instance.
</span>
}
position={Position.TOP}
className={Classes.DARK}
>
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
</Tooltip>
</td>
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
</tr>
<tr>
<td>Known peers</td>
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr>
</tbody>
</StyledHTMLTable>
);
};
private renderNeighbors = () => {
if (!this.state.neighbors) {
return;
}
const neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map(
(neighborDetails: any, idx: number) => (
<tr key={idx}>
<td>
<Link
to={`/instance/${neighborDetails.neighbor}`}
className={`${Classes.BUTTON} ${Classes.MINIMAL}`}
role="button"
>
{neighborDetails.neighbor}
</Link>
</td>
<td>{neighborDetails.weight.toFixed(4)}</td>
</tr>
)
);
return (
<div>
<p className={Classes.TEXT_MUTED}>
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.
</p>
<StyledHTMLTable small={true} striped={true} interactive={false}>
<thead>
<tr>
<th>Instance</th>
<th>Mention ratio</th>
</tr>
</thead>
<tbody>{neighborRows}</tbody>
</StyledHTMLTable>
</div>
);
};
private renderPeers = () => {
const peers = this.props.instanceDetails!.peers;
if (!peers || peers.length === 0) {
return;
}
const peerRows = peers.map(instance => (
<tr key={instance.name}>
<td>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{instance.name}
</Link>
</td>
</tr>
));
return (
<div>
<p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about.
</p>
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody>
</StyledHTMLTable>
</div>
);
};
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => {
return (
<NonIdealState
icon={IconNames.BLOCKED_PERSON}
title="No data"
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
action={
<AnchorButton icon={IconNames.CONFIRM} href="https://cursed.technology/@fediversespace" target="_blank">
Message @fediversespace to opt in
</AnchorButton>
}
/>
);
};
private renderMissingDataState = () => {
return (
<>
<NonIdealState
icon={IconNames.ERROR}
title="No data"
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
/>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
{this.props.instanceDetails && this.props.instanceDetails.status}
</span>
</>
);
};
private renderRobotsTxtState = () => {
return (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
</span>
}
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;

View File

@ -0,0 +1,136 @@
import { Button, Classes, H2, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router";
import React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { updateSearch } from "../../redux/actions";
import { IAppState, ISearchResultInstance } from "../../redux/types";
import { SearchResult } from "../molecules";
const SearchContainer = styled.div`
align-self: center;
text-align: center;
width: 100%;
`;
const SearchBarContainer = styled.div`
width: 80%;
margin: 0 auto;
text-align: center;
`;
const SearchResults = styled.div`
width: 100%;
`;
const StyledSpinner = styled(Spinner)`
margin-top: 10px;
`;
interface ISearchScreenProps {
error: boolean;
isLoadingResults: boolean;
query: string;
hasMoreResults: boolean;
results: ISearchResultInstance[];
handleSearch: (query: string) => void;
navigateToInstance: (domain: string) => void;
}
interface ISearchScreenState {
currentQuery: string;
}
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
public constructor(props: ISearchScreenProps) {
super(props);
this.state = { currentQuery: "" };
}
public componentDidMount() {
if (this.props.query) {
this.setState({ currentQuery: this.props.query });
}
}
public render() {
const { error, hasMoreResults, results, isLoadingResults, query } = this.props;
if (error) {
return <NonIdealState icon={IconNames.ERROR} title="Something went wrong." action={this.renderSearchBar()} />;
} else if (!isLoadingResults && query && results.length === 0) {
return (
<NonIdealState
icon={IconNames.SEARCH}
title="No search results"
description="Try searching for something else."
action={this.renderSearchBar()}
/>
);
}
return (
<SearchContainer>
<H2>Find an instance</H2>
{this.renderSearchBar()}
<SearchResults>
{results.map(result => (
<SearchResult result={result} key={result.name} onClick={this.selectInstanceFactory(result.name)} />
))}
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
{!isLoadingResults && hasMoreResults && (
<Button onClick={this.search} minimal={true}>
Load more results
</Button>
)}
</SearchResults>
</SearchContainer>
);
}
private handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ currentQuery: event.currentTarget.value });
};
private handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" && this.state.currentQuery !== this.props.query) {
this.search();
}
};
private search = () => {
this.props.handleSearch(this.state.currentQuery);
};
private selectInstanceFactory = (domain: string) => () => {
this.props.navigateToInstance(domain);
};
private renderSearchBar = () => (
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
<input
className={Classes.INPUT}
type="search"
placeholder="Instance name"
dir="auto"
value={this.state.currentQuery}
onChange={this.handleInputChange}
onKeyPress={this.handleKeyPress}
/>
</SearchBarContainer>
);
}
const mapStateToProps = (state: IAppState) => ({
error: state.search.error,
hasMoreResults: !!state.search.next,
isLoadingResults: state.search.isLoadingResults,
query: state.search.query,
results: state.search.results
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string) => dispatch(updateSearch(query) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(SearchScreen);

View File

@ -0,0 +1,4 @@
export { default as AboutScreen } from "./AboutScreen";
export { default as GraphScreen } from "./GraphScreen";
export { default as SearchScreen } from "./SearchScreen";
export { default as InstanceScreen } from "./InstanceScreen";

View File

@ -1,5 +1,10 @@
/* 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";
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath {
domain: string;
}

View File

@ -3,7 +3,6 @@ body {
margin: 0;
padding: 50px 0 0 0;
font-family: sans-serif;
/*background-color: #30404D;*/
background-color: #293742;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue,
@ -15,50 +14,3 @@ body {
min-width: 300px;
overflow-x: hidden;
}
.fediverse-sidebar {
position: fixed;
top: 50px;
bottom: 0;
right: 0;
min-width: 400px;
width: 25%;
z-index: 20;
overflow: scroll;
overflow-x: hidden;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.fediverse-sidebar.closed {
right: -400px;
}
.fediverse-sidebar-toggle-button {
position: absolute;
top: 50px;
right: 400px;
z-index: 20;
transition-property: all;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
.fediverse-sidebar-toggle-button.closed {
right: 0;
}
.fediverse-sidebar-table {
width: 100%;
}
@media screen and (min-width: 1600px) {
.fediverse-sidebar.closed {
right: -25%;
}
.fediverse-sidebar-toggle-button {
right: 25%;
}
}

View File

@ -4,6 +4,8 @@ import "../node_modules/@blueprintjs/select/lib/css/blueprint-select.css";
import "../node_modules/normalize.css/normalize.css";
import "./index.css";
import cytoscape from "cytoscape";
import popper from "cytoscape-popper";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Provider } from "react-redux";
@ -12,16 +14,26 @@ import thunk from "redux-thunk";
import { FocusStyleManager } from "@blueprintjs/core";
import { AppRouter } from "./AppRouter";
import { rootReducer } from "./redux/reducers";
import { routerMiddleware } from "connected-react-router";
import { createBrowserHistory } from "history";
import AppRouter from "./AppRouter";
import createRootReducer from "./redux/reducers";
// https://blueprintjs.com/docs/#core/accessibility.focus-management
FocusStyleManager.onlyShowFocusOnTabs();
export const history = createBrowserHistory();
// Initialize redux
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));
const store = createStore(
createRootReducer(history),
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))
);
// Initialize cytoscape plugins
cytoscape.use(popper as any);
ReactDOM.render(
<Provider store={store}>
@ -29,3 +41,9 @@ ReactDOM.render(
</Provider>,
document.getElementById("root") as HTMLElement
);
// if (process.env.NODE_ENV !== "production") {
// // tslint:disable-next-line:no-var-requires
// const axe = require("react-axe");
// axe(React, ReactDOM, 5000);
// }

View File

@ -1,13 +1,25 @@
import { Dispatch } from "redux";
import { push } from "connected-react-router";
import { getFromApi } from "../util";
import { ActionType, IGraph, IInstance, IInstanceDetails } from "./types";
import { ActionType, IAppState, IGraph, IInstanceDetails, ISearchResponse } from "./types";
// selectInstance and deselectInstance are not exported since we only call them from selectAndLoadInstance()
const selectInstance = (instanceName: string) => {
// Instance details
const requestInstanceDetails = (instanceName: string) => {
return {
payload: instanceName,
type: ActionType.SELECT_INSTANCE
type: ActionType.REQUEST_INSTANCE_DETAILS
};
};
const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
return {
payload: instanceDetails,
type: ActionType.RECEIVE_INSTANCE_DETAILS
};
};
const instanceLoadFailed = () => {
return {
type: ActionType.INSTANCE_LOAD_ERROR
};
};
const deselectInstance = () => {
@ -16,71 +28,85 @@ const deselectInstance = () => {
};
};
export const requestInstances = () => {
return {
type: ActionType.REQUEST_INSTANCES
};
};
export const receiveInstances = (instances: IInstance[]) => {
return {
payload: instances,
type: ActionType.RECEIVE_INSTANCES
};
};
export const requestGraph = () => {
// Graph
const requestGraph = () => {
return {
type: ActionType.REQUEST_GRAPH
};
};
export const receiveGraph = (graph: IGraph) => {
const receiveGraph = (graph: IGraph) => {
return {
payload: graph,
type: ActionType.RECEIVE_GRAPH
};
};
const graphLoadFailed = () => {
return {
type: ActionType.GRAPH_LOAD_ERROR
};
};
const instanceLoadFailed = () => {
// Search
const requestSearchResult = (query: string) => {
return {
type: ActionType.INSTANCE_LOAD_ERROR
payload: query,
type: ActionType.REQUEST_SEARCH_RESULTS
};
};
const receiveSearchResults = (result: ISearchResponse) => {
return {
payload: result,
type: ActionType.RECEIVE_SEARCH_RESULTS
};
};
const searchFailed = () => {
return {
type: ActionType.SEARCH_RESULTS_ERROR
};
};
export const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
const resetSearch = () => {
return {
payload: instanceDetails,
type: ActionType.RECEIVE_INSTANCE_DETAILS
type: ActionType.RESET_SEARCH
};
};
/** Async actions: https://redux.js.org/advanced/asyncactions */
export const fetchInstances = () => {
return (dispatch: Dispatch) => {
dispatch(requestInstances());
return getFromApi("instances")
.then(instances => dispatch(receiveInstances(instances)))
.catch(e => dispatch(graphLoadFailed()));
export const loadInstance = (instanceName: string | null) => {
return (dispatch: Dispatch, getState: () => IAppState) => {
if (!instanceName) {
dispatch(deselectInstance());
if (getState().router.location.pathname.startsWith("/instance/")) {
dispatch(push("/"));
}
return;
}
dispatch(requestInstanceDetails(instanceName));
return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details)))
.catch(() => dispatch(instanceLoadFailed()));
};
};
export const selectAndLoadInstance = (instanceName: string) => {
return (dispatch: Dispatch) => {
if (!instanceName) {
dispatch(deselectInstance());
export const updateSearch = (query: string) => {
return (dispatch: Dispatch, getState: () => IAppState) => {
query = query.trim();
if (!query) {
dispatch(resetSearch());
return;
}
dispatch(selectInstance(instanceName));
return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details)))
.catch(e => dispatch(instanceLoadFailed()));
const next = getState().search.next;
let url = `search/?query=${query}`;
if (next) {
url += `&after=${next}`;
}
dispatch(requestSearchResult(query));
return getFromApi(url)
.then(result => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed()));
};
};
@ -89,6 +115,6 @@ export const fetchGraph = () => {
dispatch(requestGraph());
return getFromApi("graph")
.then(graph => dispatch(receiveGraph(graph)))
.catch(e => dispatch(graphLoadFailed()));
.catch(() => dispatch(graphLoadFailed()));
};
};

View File

@ -1,29 +1,19 @@
import { connectRouter } from "connected-react-router";
import { combineReducers } from "redux";
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types";
import { History } from "history";
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
const initialDataState = {
error: false,
isLoadingGraph: false,
isLoadingInstances: false
isLoadingGraph: false
};
const data = (state: IDataState = initialDataState, action: IAction) => {
switch (action.type) {
case ActionType.REQUEST_INSTANCES:
return {
...state,
instances: [],
isLoadingInstances: true
};
case ActionType.RECEIVE_INSTANCES:
return {
...state,
instances: action.payload,
isLoadingInstances: false
};
case ActionType.REQUEST_GRAPH:
return {
...state,
graph: undefined,
isLoadingGraph: true
};
case ActionType.RECEIVE_GRAPH:
@ -36,8 +26,7 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
return {
...state,
error: true,
isLoadingGraph: false,
isLoadingInstances: false
isLoadingGraph: false
};
default:
return state;
@ -46,29 +35,29 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
const initialCurrentInstanceState: ICurrentInstanceState = {
currentInstanceDetails: null,
currentInstanceName: null,
error: false,
isLoadingInstanceDetails: false
};
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => {
switch (action.type) {
case ActionType.SELECT_INSTANCE:
case ActionType.REQUEST_INSTANCE_DETAILS:
return {
...state,
currentInstanceName: action.payload,
error: false,
isLoadingInstanceDetails: true
};
case ActionType.RECEIVE_INSTANCE_DETAILS:
return {
...state,
currentInstanceDetails: action.payload,
error: false,
isLoadingInstanceDetails: false
};
case ActionType.DESELECT_INSTANCE:
return {
...state,
currentInstanceDetails: null,
currentInstanceName: null
error: false
};
case ActionType.INSTANCE_LOAD_ERROR:
return {
@ -81,7 +70,54 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
}
};
export const rootReducer = combineReducers({
currentInstance,
data
});
const initialSearchState: ISearchState = {
error: false,
isLoadingResults: false,
next: "",
query: "",
results: []
};
const search = (state = initialSearchState, action: IAction): ISearchState => {
switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS:
const query = action.payload;
const isNewQuery = state.query !== query;
return {
...state,
error: false,
isLoadingResults: true,
query,
results: isNewQuery ? [] : state.results
};
case ActionType.RECEIVE_SEARCH_RESULTS:
return {
...state,
error: false,
isLoadingResults: false,
next: action.payload.next,
results: state.results.concat(action.payload.results)
};
case ActionType.SEARCH_RESULTS_ERROR:
return {
...state,
error: true,
isLoadingResults: false,
next: "",
query: "",
results: []
};
case ActionType.RESET_SEARCH:
return initialSearchState;
default:
return state;
}
};
export default (history: History) =>
combineReducers({
router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys
currentInstance,
data,
search
});

View File

@ -1,13 +1,23 @@
import { RouterState } from "connected-react-router";
export enum ActionType {
SELECT_INSTANCE = "SELECT_INSTANCE",
REQUEST_INSTANCES = "REQUEST_INSTANCES",
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
// Instance details
REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS",
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR",
// Graph
REQUEST_GRAPH = "REQUEST_GRAPH",
RECEIVE_GRAPH = "RECEIVE_GRAPH",
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
DESELECT_INSTANCE = "DESELECT_INSTANCE",
GRAPH_LOAD_ERROR = "GRAPH_LOAD_ERROR",
INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR"
// Nav
DESELECT_INSTANCE = "DESELECT_INSTANCE",
// Search
REQUEST_SEARCH_RESULTS = "REQUEST_SEARCH_RESULTS",
RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS",
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
RESET_SEARCH = "RESET_SEARCH"
// REQUEST_INSTANCES = "REQUEST_INSTANCES",
// RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
}
export interface IAction {
@ -19,6 +29,12 @@ export interface IInstance {
name: string;
}
export interface ISearchResultInstance {
name: string;
description?: string;
userCount?: number;
}
export interface IInstanceDetails {
name: string;
description?: string;
@ -58,24 +74,37 @@ export interface IGraph {
edges: IGraphEdge[];
}
export interface ISearchResponse {
results: ISearchResultInstance[];
next: string | null;
}
// Redux state
// The current instance name is stored in the URL. See state -> router -> location
export interface ICurrentInstanceState {
currentInstanceDetails: IInstanceDetails | null;
currentInstanceName: string | null;
isLoadingInstanceDetails: boolean;
error: boolean;
}
export interface IDataState {
instances?: IInstance[];
graph?: IGraph;
isLoadingInstances: boolean;
isLoadingGraph: boolean;
error: boolean;
}
export interface ISearchState {
error: boolean;
isLoadingResults: boolean;
next: string;
query: string;
results: ISearchResultInstance[];
}
export interface IAppState {
router: RouterState;
currentInstance: ICurrentInstanceState;
data: IDataState;
search: ISearchState;
}

View File

@ -1,4 +1,7 @@
import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types";
let API_ROOT = "http://localhost:4000/api/";
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
@ -9,6 +12,9 @@ if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
export const getFromApi = (path: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
path = path.endsWith("/") ? path : path + "/";
return fetch(domain + path).then(response => response.json());
return fetch(encodeURI(domain + path)).then(response => response.json());
};
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;

View File

@ -1318,10 +1318,10 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.9.tgz#d868b6febb02666330410fe7f58f3c4b8258be7b"
integrity sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A==
"@types/cytoscape@^3.4.3":
version "3.4.3"
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.4.3.tgz#8b9353154dc895231cd344ed1c7eff2d1391c103"
integrity sha512-uADb/vBj/xTeNNRvtYlzPz1rftMR4Jf6ipq4jqKfYibMZ173sAbdFM3Fl2fPbGfP28CWJpqhcpHp4+NUq3Ma4g==
"@types/cytoscape@^3.8.0":
version "3.8.0"
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.8.0.tgz#334006612fc6285dac83ee3665132743d7651f58"
integrity sha512-8TJL7HuMEgjQRCcUC3xKenb7Y6Ra3ZJ3LvYDlpxlt5LlAatzRyTBtIuE1JOdRelqAla8r87XJUzTgi92mlUlQQ==
"@types/dom4@^2.0.1":
version "2.0.1"
@ -1376,6 +1376,11 @@
"@types/domutils" "*"
"@types/node" "*"
"@types/inflection@^1.5.28":
version "1.5.28"
resolved "https://registry.yarnpkg.com/@types/inflection/-/inflection-1.5.28.tgz#43d55e0d72cf333a2dffd9c4ec0407455a1b0931"
integrity sha1-Q9VeDXLPMzot/9nE7AQHRVobCTE=
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff"
@ -1423,10 +1428,10 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca"
integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==
"@types/node@^12.6.2":
version "12.6.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.2.tgz#a5ccec6abb6060d5f20d256fb03ed743e9774999"
integrity sha512-gojym4tX0FWeV2gsW4Xmzo5wxGjXGm550oVUII7f7G5o4BV6c7DBdiG1RRQd+y1bvqRyYtPfMK85UM95vsapqQ==
"@types/node@^12.6.8":
version "12.6.8"
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.8.tgz#e469b4bf9d1c9832aee4907ba8a051494357c12c"
integrity sha512-aX+gFgA5GHcDi89KG5keey2zf0WfZk/HAQotEamsK2kbey+8yGKcson0hbK8E+v0NArlCJQCqMP161YhV6ZXLg==
"@types/normalize-package-data@^2.4.0":
version "2.4.0"
@ -1490,10 +1495,10 @@
"@types/history" "*"
"@types/react" "*"
"@types/react-virtualized@^9.21.2":
version "9.21.2"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.2.tgz#c5e4293409593814c35466913e83fb856e2053d0"
integrity sha512-Q6geJaDd8FlBw3ilD4ODferTyVtYAmDE3d7+GacfwN0jPt9rD9XkeuPjcHmyIwTrMXuLv1VIJmRxU9WQoQFBJw==
"@types/react-virtualized@^9.21.3":
version "9.21.3"
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.3.tgz#79a44b870a4848cbc7cc04ff4bc06e5a10955262"
integrity sha512-QhXeiVwXrshVAoq2Cy3SGZEDiFdeFfup2ciQya5RTgr5uycQ2alIKzLfy4X38UCrxonwxe8byk5q8fYV0U87Zg==
dependencies:
"@types/prop-types" "*"
"@types/react" "*"
@ -2093,6 +2098,11 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
axe-core@^3.0.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-3.3.0.tgz#3b32d7e54390d89ff4891b20394d33ad7a192776"
integrity sha512-54XaTd2VB7A6iBnXMUG2LnBOI7aRbnrVxC5Tz+rVUwYl9MX/cIJc/Ll32YUoFIE/e9UKWMZoQenQu9dFrQyZCg==
axobject-query@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.0.2.tgz#ea187abe5b9002b377f925d8bf7d1c561adf38f9"
@ -2961,6 +2971,15 @@ connect-history-api-fallback@^1.3.0:
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc"
integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==
connected-react-router@^6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.5.2.tgz#422af70f86cb276681e20ab4295cf27dd9b6c7e3"
integrity sha512-qzsLPZCofSI80fwy+HgxtEgSGS4ndYUUZAWaw1dqaOGPLKX/FVwIOEb7q+hjHdnZ4v5pKZcNv5GG4urjujIoyA==
dependencies:
immutable "^3.8.1"
prop-types "^15.7.2"
seamless-immutable "^7.1.3"
console-browserify@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
@ -5159,11 +5178,12 @@ https-browserify@^1.0.0:
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=
husky@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.0.tgz#de63821a7049dc412b1afd753c259e2f6e227562"
integrity sha512-lKMEn7bRK+7f5eWPNGclDVciYNQt0GIkAQmhKl+uHP1qFzoN0h92kmH9HZ8PCwyVA2EQPD8KHf0FYWqnTxau+Q==
husky@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.1.tgz#06152c28e129622b05fa09c494209de8cf2dfb59"
integrity sha512-PXBv+iGKw23GHUlgELRlVX9932feFL407/wHFwtsGeArp0dDM4u+/QusSQwPKxmNgjpSL+ustbOdQ2jetgAZbA==
dependencies:
chalk "^2.4.2"
cosmiconfig "^5.2.1"
execa "^1.0.0"
get-stdin "^7.0.0"
@ -5240,6 +5260,11 @@ immer@1.10.0:
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
immutable@^3.8.1:
version "3.8.2"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
import-cwd@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@ -5298,6 +5323,11 @@ indexof@0.0.1:
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
inflection@^1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@ -6657,11 +6687,16 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
lodash@^4.17.12, lodash@^4.17.14:
lodash@^4.17.12:
version "4.17.14"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
lodash@^4.17.15:
version "4.17.15"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
log-symbols@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
@ -8831,6 +8866,14 @@ react-app-polyfill@^1.0.1:
regenerator-runtime "0.13.2"
whatwg-fetch "3.0.0"
react-axe@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-axe/-/react-axe-3.2.0.tgz#d17427e5d54d6c4561e74ad9cf8c1839e411bde3"
integrity sha512-2KlO2wZq58+GSFP4oWA2ZjU1ggbXdDLJc7tMUXUXkE4NVQ3FftdYtb7qNR+x1nTLeuVYiH4nH4hzIz9vQZ/Chw==
dependencies:
axe-core "^3.0.0"
requestidlecallback "^0.3.0"
react-dev-utils@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-9.0.1.tgz#5c03d85a0b2537d0c46af7165c24a7dfb274bef2"
@ -8862,7 +8905,7 @@ react-dev-utils@^9.0.1:
strip-ansi "5.2.0"
text-table "0.2.0"
react-dom@^16.4.2:
react-dom@^16.8.0:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
@ -9028,7 +9071,7 @@ react-virtualized@^9.21.1:
prop-types "^15.6.0"
react-lifecycles-compat "^3.0.4"
react@^16.4.2:
react@^16.8.0:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
@ -9306,6 +9349,11 @@ request@^2.87.0, request@^2.88.0:
tunnel-agent "^0.6.0"
uuid "^3.3.2"
requestidlecallback@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/requestidlecallback/-/requestidlecallback-0.3.0.tgz#6fb74e0733f90df3faa4838f9f6a2a5f9b742ac5"
integrity sha1-b7dOBzP5DfP6pIOPn2oqX5t0KsU=
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
@ -9538,6 +9586,11 @@ schema-utils@^1.0.0:
ajv-errors "^1.0.0"
ajv-keywords "^3.1.0"
seamless-immutable@^7.1.3:
version "7.1.4"
resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8"
integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"

View File

@ -9,4 +9,10 @@
REACT_APP_STAGING = "true"
[context.deploy-preview.environment]
REACT_APP_STAGING = "true"
REACT_APP_STAGING = "true"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200