Merge branch 'develop' into 'production'

Update prod

See merge request taobojlen/fediverse.space!51
This commit is contained in:
Tao Bojlén 2019-07-03 11:28:47 +00:00
commit 0f2150325a
31 changed files with 7635 additions and 3965 deletions

32
BILL-OF-MATERIALS.md Normal file
View File

@ -0,0 +1,32 @@
# Software Bill of Materials
This is an overview of the external software components (libraries, etc.) that
are used in fediverse.space, or that are likely to be used.
## Backend
I am currently in the process of migrating from a Python and Django-based
backend to one written in Elixir. This list is what *will* be used in the near
future.
### Crawler and API
* [Elixir](https://elixir-lang.org/) (the language)
* [Phoenix](https://phoenixframework.org/) (the web framework)
* [HTTPoison](https://hexdocs.pm/httpoison/readme.html) (for crawling servers)
* See [/backend/mix.env](/backend/mix.env) for a complete overview of
dependencies
### Graph layout
* [Gephi toolkit](https://gephi.org/toolkit/)
## Frontend
* [React](https://reactjs.org/) (the UI framework)
* [Blueprint](https://blueprintjs.com/) (a collection of pre-existing UI components)
* [Sigma.js](http://sigmajs.org/) (for graph visualization)
* See [/frontend/package.json](/frontend/package.json) for a complete overview
of dependencies
## Other
* [Docker](https://www.docker.com/) and
[docker-compose](https://docs.docker.com/compose/overview/)
* The frontend is hosted on [Netlify](https://www.netlify.com/)

View File

@ -38,6 +38,17 @@ After running the backend in Docker:
To run in production, use `docker-compose -f docker-compose.yml -f docker-compose.production.yml` instead of just `docker-compose`.
An example crontab:
```
# crawl 50 stale instances (plus any newly discovered instances from them)
# the -T flag is important; without it, docker-compose will allocate a tty to the process
15,45 * * * * docker-compose -f docker-compose.yml -f docker-compose.production.yml exec -T django python manage.py scrape
# build the edges based on how much users interact
15 3 * * * docker-compose -f docker-compose.yml -f docker-compose.production.yml exec -T django python manage.py build_edges
# layout the graph
20 3 * * * docker-compose -f docker-compose.yml -f docker-compose.production.yml run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
```
### Frontend
- `yarn build` to create an optimized build for deployment

View File

@ -32,8 +32,6 @@ class NodeView(viewsets.ReadOnlyModelViewSet):
"""
Endpoint to get a list of the graph's nodes in a SigmaJS-friendly format.
"""
queryset = Instance.objects.filter(status='success')\
.filter(x_coord__isnull=False)\
.filter(y_coord__isnull=False)\
.filter(user_count__isnull=False)
queryset = Instance.objects.filter(status='success', x_coord__isnull=False, y_coord__isnull=False, user_count__isnull=False)\
.exclude(sources__isnull=True, targets__isnull=True)
serializer_class = NodeSerializer

View File

@ -54,6 +54,11 @@ class PersonalInstanceException(Exception):
"""
pass
class BlacklistedDomainException(Exception):
"""
Used for instances whose domain is blacklisted.
"""
pass
def get_key(data, keys: list):
try:

View File

@ -17,11 +17,11 @@ from django import db
from django.conf import settings
from django.utils import timezone
from scraper.models import Instance, PeerRelationship
from scraper.management.commands._util import require_lock, InvalidResponseException, get_key, log, validate_int, PersonalInstanceException
from scraper.management.commands._util import require_lock, InvalidResponseException, get_key, log, validate_int, PersonalInstanceException, BlacklistedDomainException
# TODO: use the /api/v1/server/followers and /api/v1/server/following endpoints in peertube instances
SEED = 'p.a3.pm'
SEED = 'mastodon.social'
TIMEOUT = 20 # seconds
NUM_THREADS = 16 # roughly 40MB each
PERSONAL_INSTANCE_THRESHOLD = 10 # instances with < this many users won't be crawled
@ -142,6 +142,9 @@ class Command(BaseCommand):
"""Given an instance, get all the data we're interested in"""
data = dict()
try:
if instance.name.endswith("gab.best"):
raise BlacklistedDomainException
data['instance_name'] = instance.name
data['info'] = self.get_instance_info(instance.name)
@ -163,6 +166,7 @@ class Command(BaseCommand):
except (InvalidResponseException,
PersonalInstanceException,
BlacklistedDomainException,
requests.exceptions.RequestException,
json.decoder.JSONDecodeError) as e:
data['instance_name'] = instance.name

View File

@ -0,0 +1,24 @@
# Generated by Django 2.1.7 on 2019-04-19 13:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('scraper', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='edge',
name='source',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='targets', to='scraper.Instance'),
),
migrations.AlterField(
model_name='edge',
name='target',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sources', to='scraper.Instance'),
),
]

View File

@ -51,8 +51,8 @@ class Edge(models.Model):
It aggregates stats from the asymmetrical PeerRelationship to a symmetrical one that's suitable for serving
to the front-end.
"""
source = models.ForeignKey(Instance, related_name='+', on_delete=models.CASCADE)
target = models.ForeignKey(Instance, related_name='+', on_delete=models.CASCADE)
source = models.ForeignKey(Instance, related_name='targets', on_delete=models.CASCADE)
target = models.ForeignKey(Instance, related_name='sources', on_delete=models.CASCADE)
weight = models.FloatField(blank=True, null=True)
# Metadata

View File

@ -1,3 +0,0 @@
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'

View File

@ -2,41 +2,74 @@
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "NODE_ENV=development react-scripts start",
"build": "react-scripts build",
"lint": "tslint -p tsconfig.json -c tslint.json \"src/**/*.{ts,tsx}\"",
"lint:fix": "yarn lint --fix",
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
"test": "yarn lint && react-scripts test",
"eject": "react-scripts eject"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"yarn pretty",
"yarn lint:fix",
"git add"
]
},
"prettier": {
"printWidth": 120
},
"dependencies": {
"@blueprintjs/core": "^3.4.0",
"@blueprintjs/icons": "^3.1.0",
"@blueprintjs/select": "^3.1.0",
"classnames": "^2.2.6",
"cross-fetch": "^2.2.2",
"cross-fetch": "^3.0.2",
"lodash": "^4.17.10",
"moment": "^2.22.2",
"normalize.css": "^8.0.0",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-redux": "^5.0.7",
"react-scripts-ts": "2.17.0",
"react-redux": "^7.0.2",
"react-router-dom": "^5.0.0",
"react-scripts": "^2.1.8",
"react-sigma": "^1.2.30",
"react-virtualized": "^9.20.1",
"redux": "^4.0.0",
"redux-thunk": "^2.3.0",
"sanitize-html": "^1.18.4"
},
"scripts": {
"start": "NODE_ENV=development react-scripts-ts start",
"build": "react-scripts-ts build",
"test": "react-scripts-ts test --env=jsdom",
"eject": "react-scripts-ts eject"
"sanitize-html": "^1.18.4",
"styled-components": "^4.2.0"
},
"devDependencies": {
"@blueprintjs/tslint-config": "^1.8.0",
"@types/classnames": "^2.2.6",
"@types/jest": "^23.3.1",
"@types/jest": "^24.0.11",
"@types/lodash": "^4.14.116",
"@types/node": "^10.9.2",
"@types/react": "^16.4.12",
"@types/react-dom": "^16.0.7",
"@types/react-redux": "^6.0.6",
"@types/node": "^11.13.4",
"@types/react": "^16.8.13",
"@types/react-dom": "^16.8.4",
"@types/react-redux": "^7.0.6",
"@types/react-router-dom": "^4.3.2",
"@types/react-virtualized": "^9.18.7",
"@types/sanitize-html": "^1.18.0",
"@types/sanitize-html": "^1.18.3",
"@types/styled-components": "4.1.8",
"husky": "^1.3.1",
"lint-staged": "^8.1.5",
"tslint": "^5.16.0",
"tslint-eslint-rules": "^5.4.0",
"typescript": "^3.0.1"
}
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}

View File

@ -1,138 +0,0 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import { Button, Classes, Dialog, NonIdealState, Spinner } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import ErrorState from './components/ErrorState';
import { Graph } from './components/Graph';
import { Nav } from './components/Nav';
import { Sidebar } from './components/Sidebar';
import { DESKTOP_WIDTH_THRESHOLD } from './constants';
import { fetchGraph, fetchInstances } from './redux/actions';
import { IAppState, IGraph, IInstance } from './redux/types';
interface IAppProps {
graph?: IGraph;
instances?: IInstance[],
isLoadingGraph: boolean;
isLoadingInstances: boolean,
graphLoadError: boolean,
fetchInstances: () => void;
fetchGraph: () => void;
}
interface IAppLocalState {
mobileDialogOpen: boolean;
}
class AppImpl extends React.Component<IAppProps, IAppLocalState> {
constructor(props: IAppProps) {
super(props);
this.state = { mobileDialogOpen: false };
}
public render() {
let body = <div />;
if (this.props.isLoadingInstances || this.props.isLoadingGraph) {
body = this.loadingState("Loading...");
} else {
body = this.graphState();
}
return (
<div className="App bp3-dark">
<Nav />
{body}
{this.renderMobileDialog()}
</div>
);
}
public componentDidMount() {
if (window.innerWidth < DESKTOP_WIDTH_THRESHOLD) {
this.handleMobileDialogOpen();
}
this.load();
}
public componentDidUpdate() {
this.load();
}
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 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 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 mapStateToProps = (state: IAppState) => ({
graph: state.data.graph,
graphLoadError: state.data.error,
instances: state.data.instances,
isLoadingGraph: state.data.isLoadingGraph,
isLoadingInstances: state.data.isLoadingInstances,
})
const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
fetchInstances: () => dispatch(fetchInstances() as any),
})
export const App = connect(mapStateToProps, mapDispatchToProps)(AppImpl)

View File

@ -0,0 +1,71 @@
import * as React from "react";
import { Button, Classes, Dialog } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
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";
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 });
};
}

View File

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

View File

@ -1,102 +1,107 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { Sigma, SigmaEnableWebGL, Filter, ForceAtlas2 } from 'react-sigma';
import * as React from "react";
import { connect } from "react-redux";
import { Sigma, Filter, ForceAtlas2 } from "react-sigma";
import { selectAndLoadInstance } from '../redux/actions';
import ErrorState from './ErrorState';
import { selectAndLoadInstance } from "../redux/actions";
import { ErrorState } from "./ErrorState";
const STYLE = {
bottom: "0",
left: "0",
position: "absolute",
right: "0",
top: "50px",
}
bottom: "0",
left: "0",
position: "absolute",
right: "0",
top: "50px"
};
const DEFAULT_NODE_COLOR = "#CED9E0";
const SELECTED_NODE_COLOR = "#48AFF0";
const SETTINGS = {
defaultEdgeColor: "#5C7080",
defaultLabelColor: "#F5F8FA",
defaultNodeColor: DEFAULT_NODE_COLOR,
drawEdges: true,
drawLabels: true,
edgeColor: "default",
labelColor: "default",
labelThreshold: 10,
maxEdgeSize: 1,
minEdgeSize: 0.3,
}
defaultEdgeColor: "#5C7080",
defaultLabelColor: "#F5F8FA",
defaultNodeColor: DEFAULT_NODE_COLOR,
drawEdges: true,
drawLabels: true,
edgeColor: "default",
labelColor: "default",
labelThreshold: 10,
maxEdgeSize: 1,
minEdgeSize: 0.3
};
class GraphImpl extends React.Component {
constructor(props) {
super(props);
this.sigmaComponent = React.createRef();
}
constructor(props) {
super(props);
this.sigmaComponent = React.createRef();
render() {
let graph = this.props.graph;
if (!graph) {
return <ErrorState />;
}
// Check that all nodes have size & coordinates; otherwise the graph will look messed up
const lengthBeforeFilter = graph.nodes.length;
graph = { ...graph, nodes: graph.nodes.filter(n => n.size && n.x && n.y) };
if (graph.nodes.length !== lengthBeforeFilter) {
// tslint:disable-next-line:no-console
console.error(
"Some nodes were missing details: " +
this.props.graph.nodes.filter(n => !n.size || !n.x || !n.y).map(n => n.label)
);
return <ErrorState />;
}
return (
<Sigma
graph={graph}
renderer="webgl"
settings={SETTINGS}
style={STYLE}
onClickNode={this.onClickNode}
onClickStage={this.onClickStage}
ref={this.sigmaComponent}
>
<Filter neighborsOf={this.props.currentInstanceName} />
<ForceAtlas2 iterationsPerRender={1} timeout={10000} />
</Sigma>
);
}
render() {
let graph = this.props.graph;
if (!graph) {
return <ErrorState />;
}
// Check that all nodes have size & coordinates; otherwise the graph will look messed up
const lengthBeforeFilter = graph.nodes.length;
graph = {...graph, nodes: graph.nodes.filter(n => n.size && n.x && n.y)};
if (graph.nodes.length !== lengthBeforeFilter) {
// tslint:disable-next-line:no-console
console.error("Some nodes were missing details: " + this.props.graph.nodes.filter(n => !n.size || !n.x || !n.y).map(n => n.label));
return <ErrorState />;
}
return (
<Sigma
graph={graph}
renderer="webgl"
settings={SETTINGS}
style={STYLE}
onClickNode={this.onClickNode}
onClickStage={this.onClickStage}
ref={this.sigmaComponent}
>
<Filter neighborsOf={this.props.currentInstanceName} />
<ForceAtlas2 iterationsPerRender={1} timeout={10000}/>
</Sigma>
)
componentDidUpdate() {
const sigma = this.sigmaComponent && this.sigmaComponent.current.sigma;
// Check if sigma exists s.t. nothing breaks if the graph didn't load (for whatever reason)
if (sigma) {
sigma.graph.nodes().map(this.colorNodes);
sigma.refresh();
}
}
componentDidUpdate() {
const sigma = this.sigmaComponent && this.sigmaComponent.current.sigma;
// Check if sigma exists s.t. nothing breaks if the graph didn't load (for whatever reason)
if (sigma) {
sigma.graph.nodes().map(this.colorNodes);
sigma.refresh();
}
}
onClickNode = e => {
this.props.selectAndLoadInstance(e.data.node.label);
};
onClickNode = (e) => {
this.props.selectAndLoadInstance(e.data.node.label);
onClickStage = e => {
// Deselect the instance (unless this was a drag event)
if (!e.data.captor.isDragging) {
this.props.selectAndLoadInstance(null);
}
};
onClickStage = (e) => {
// Deselect the instance (unless this was a drag event)
if (!e.data.captor.isDragging) {
this.props.selectAndLoadInstance(null);
}
}
colorNodes = (n) => {
if (this.props.currentInstanceName && n.id === this.props.currentInstanceName) {
n.color = SELECTED_NODE_COLOR;
} else {
n.color = DEFAULT_NODE_COLOR;
}
colorNodes = n => {
if (this.props.currentInstanceName && n.id === this.props.currentInstanceName) {
n.color = SELECTED_NODE_COLOR;
} else {
n.color = DEFAULT_NODE_COLOR;
}
};
}
const mapStateToProps = (state) => ({
currentInstanceName: state.currentInstance.currentInstanceName,
graph: state.data.graph,
})
const mapDispatchToProps = (dispatch) => ({
selectAndLoadInstance: (instanceName) => dispatch(selectAndLoadInstance(instanceName)),
})
export const Graph = connect(mapStateToProps, mapDispatchToProps)(GraphImpl)
const mapStateToProps = state => ({
currentInstanceName: state.currentInstance.currentInstanceName,
graph: state.data.graph
});
const mapDispatchToProps = dispatch => ({
selectAndLoadInstance: instanceName => dispatch(selectAndLoadInstance(instanceName))
});
export const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);

View File

@ -1,89 +1,85 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
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 { Button, MenuItem } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { IItemRendererProps, ItemPredicate, Select } from "@blueprintjs/select";
import { selectAndLoadInstance } from '../redux/actions';
import { IAppState, IInstance } from '../redux/types';
import { RouteComponentProps, withRouter } from "react-router";
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IInstance } from "../redux/types";
interface IInstanceSearchProps {
currentInstanceName: string | null;
instances?: IInstance[];
selectAndLoadInstance: (instanceName: string) => void;
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>
);
}
public render() {
return (
<InstanceSelect
items={this.props.instances || []}
itemRenderer={this.itemRenderer}
onItemSelect={this.onItemSelect}
itemPredicate={this.itemPredicate}
disabled={!this.props.instances}
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 renderInitialContent = () => {
return (
<MenuItem disabled={true} text={"Start typing"} />
);
}
private renderNoResults = () => {
return <MenuItem disabled={true} text={"Keep 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 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 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);
}
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,
})
currentInstanceName: state.currentInstance.currentInstanceName,
instances: state.data.instances
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any),
})
export const InstanceSearch = connect(mapStateToProps, mapDispatchToProps)(InstanceSearchImpl)
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
export const InstanceSearch = withRouter(
connect(
mapStateToProps,
mapDispatchToProps
)(InstanceSearchImpl)
);

View File

@ -1,133 +1,41 @@
import * as React from 'react';
import * as React from "react";
import { Alignment, Button, Classes, Dialog, Icon, Navbar } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { Alignment, Button, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { InstanceSearch } from './InstanceSearch';
import { Link } from "react-router-dom";
import styled from "styled-components";
import { InstanceSearch } from "./InstanceSearch";
interface INavState {
aboutIsOpen: boolean;
aboutIsOpen: boolean;
}
export class Nav extends React.Component<{}, INavState> {
constructor(props: any) {
super(props);
this.state = { aboutIsOpen: false };
}
constructor(props: any) {
super(props);
this.state = {aboutIsOpen: false};
}
public render() {
return (
<Navbar fixedToTop={true}>
<Navbar.Group align={Alignment.LEFT}>
<Navbar.Heading>
<Icon
icon={IconNames.GLOBE_NETWORK}
iconSize={Icon.SIZE_LARGE}
className="fediverse-heading-icon"
/>
fediverse.space
</Navbar.Heading>
<Navbar.Divider />
<Button
icon={IconNames.INFO_SIGN}
text="About"
minimal={true}
onClick={this.handleAboutOpen}
/>
{this.renderAboutDialog()}
{/* <Button
icon={<Icon icon={IconNames.GLOBE_NETWORK} />}
text="Network"
minimal={true}
/>
<Button
icon={<Icon icon={IconNames.GLOBE} />}
text="Map"
minimal={true}
/> */}
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<InstanceSearch />
</Navbar.Group>
</Navbar>
)
}
private renderAboutDialog = () => {
return (
<Dialog
icon={IconNames.INFO_SIGN}
title="About"
onClose={this.handleAboutClose}
isOpen={this.state.aboutIsOpen}
className={Classes.DARK + ' fediverse-about-dialog'}
>
<div className={Classes.DIALOG_BODY}>
<p className={Classes.RUNNING_TEXT}>
fediverse.space is a tool to visualize networks and communities on the
{' '}<a href="https://en.wikipedia.org/wiki/Fediverse" target="_blank">fediverse</a>.
It works by scraping every instance it can find and aggregating statistics on communication
between these.
</p>
<h2>FAQ</h2>
<h4>Why can't I see details about my instance?</h4>
<p className={Classes.RUNNING_TEXT}>
Currently, fediverse.space only supports Mastodon and Pleroma instances. In addition, instances
with 5 or fewer users won't be scraped -- it's a tool for understanding communities, not
individuals.
</p>
<h4>How do you calculate the strength of relationships between instances?</h4>
<p className={Classes.RUNNING_TEXT}>
fediverse.space scrapes the last 5000 statuses from within the last month on the public
timeline of each instance. It looks at the ratio of
<code>mentions of an instance / total statuses</code>.
It uses a ratio rather than an absolute number of mentions to reflect that smaller instances
can play a large role in a community.
</p>
<h2>Credits</h2>
<p className={Classes.RUNNING_TEXT}>
This site is inspired by several other sites in the same vein:
<ul className={Classes.LIST}>
<li><a href="https://the-federation.info/" target="_blank">the-federation.info</a></li>
<li><a href="http://fediverse.network/" target="_blank">fediverse.network</a></li>
<li>
<a
href="https://lucahammer.at/vis/fediverse/2018-08-30-mastoverse_hashtags/"
target="_blank"
>
Mastodon hashtag network
</a>
{' by '}
<a href="https://vis.social/web/statuses/100634284168959187" target="_blank">
@Luca@vis.social
</a>
</li>
</ul>
The source code for fediverse.space is available on{' '}
<a href="https://gitlab.com/taobojlen/fediverse.space" target="_blank">GitLab</a>;{' '}
issues and pull requests are welcome!
</p>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
icon={IconNames.THUMBS_UP}
text="OK!"
onClick={this.handleAboutClose}
/>
</div>
</div>
</Dialog>
)
}
private handleAboutOpen = () => {
this.setState({aboutIsOpen: true});
}
private handleAboutClose = () => {
this.setState({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

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

View File

@ -1,306 +1,340 @@
import { orderBy } from 'lodash';
import * as moment from 'moment';
import * as React from 'react';
import { connect } from 'react-redux';
import { Dispatch } from 'redux';
import * as sanitize from 'sanitize-html';
import { orderBy } from "lodash";
import moment from "moment";
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import sanitize from "sanitize-html";
import {
AnchorButton, Button, Card, Classes, Divider, Elevation, HTMLTable, NonIdealState, Position,
Tab, Tabs, Tooltip
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
AnchorButton,
Button,
Card,
Classes,
Code,
Divider,
Elevation,
H2,
H4,
HTMLTable,
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 ErrorState from './ErrorState';
import { selectAndLoadInstance } from "../redux/actions";
import { IAppState, IGraph, IInstanceDetails } from "../redux/types";
import { ErrorState } from "./ErrorState";
interface ISidebarProps {
graph?: IGraph,
instanceName: string | null,
instanceLoadError: boolean,
instanceDetails: IInstanceDetails | null,
isLoadingInstanceDetails: boolean;
selectAndLoadInstance: (instanceName: string) => void;
graph?: IGraph;
instanceName: string | null;
instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean;
selectAndLoadInstance: (instanceName: string) => void;
}
interface ISidebarState {
isOpen: boolean;
isOpen: boolean;
}
class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
constructor(props: ISidebarProps) {
super(props);
const isOpen = window.innerWidth >= 900 ? true : false;
this.state = { isOpen };
}
constructor(props: ISidebarProps) {
super(props);
const isOpen = window.innerWidth >= 900 ? true : false;
this.state = { isOpen };
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.THREE}>
{this.renderSidebarContents()}
</Card>
</div>
);
}
private handleToggle = () => {
this.setState({ isOpen: !this.state.isOpen });
};
private renderSidebarContents = () => {
if (this.props.isLoadingInstanceDetails) {
return this.renderLoadingState();
} else if (!this.props.instanceDetails) {
return this.renderEmptyState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personalinstance") > -1) {
return this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status !== "success") {
return this.renderMissingDataState();
} else if (this.props.instanceLoadError) {
return <ErrorState />;
} else if (
this.props.graph &&
this.props.instanceName &&
this.props.graph.nodes.map(n => n.id).indexOf(this.props.instanceName) < 0
) {
return this.renderQuietInstanceState();
}
return (
<div>
{this.renderHeading()}
<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>
);
};
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.THREE}>
{this.renderSidebarContents()}
</Card>
</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) {
content = <span>{"No instance selected"}</span>;
} 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 handleToggle = () => {
this.setState({ isOpen: !this.state.isOpen });
private renderDescription = () => {
const description = this.props.instanceDetails!.description;
if (!description) {
return;
}
return <p className={Classes.RUNNING_TEXT} dangerouslySetInnerHTML={{ __html: sanitize(description) }} />;
};
private renderSidebarContents = () => {
if (this.props.isLoadingInstanceDetails) {
return this.renderLoadingState();
} else if (!this.props.instanceDetails) {
return this.renderEmptyState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf('personalinstance') > -1) {
return this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status !== 'success') {
return this.renderMissingDataState();
} else if (this.props.instanceLoadError) {
return <ErrorState />;
}
return (
<div>
{this.renderHeading()}
<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) {
content = <span>{"No instance selected"}</span>;
} 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 = () => {
const version = this.props.instanceDetails!.version;
const userCount = this.props.instanceDetails!.userCount;
const statusCount = this.props.instanceDetails!.statusCount;
const domainCount = this.props.instanceDetails!.domainCount;
const lastUpdated = this.props.instanceDetails!.lastUpdated;
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 || "Unknown"}</td>
</tr>
<tr>
<td>Statuses</td>
<td>{statusCount || "Unknown"}</td>
</tr>
<tr>
<td>Known peers</td>
<td>{domainCount || "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.source, e.target].indexOf(this.props.instanceName!) > -1);
const neighbors: any[] = [];
edges.forEach(e => {
if (e.source === this.props.instanceName) {
neighbors.push({neighbor: e.target, weight: e.size});
} else {
neighbors.push({neighbor: e.source, weight: e.size});
}
})
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>
private renderVersionAndCounts = () => {
const version = this.props.instanceDetails!.version;
const userCount = this.props.instanceDetails!.userCount;
const statusCount = this.props.instanceDetails!.statusCount;
const domainCount = this.props.instanceDetails!.domainCount;
const lastUpdated = this.props.instanceDetails!.lastUpdated;
return (
<div>
<HTMLTable small={true} striped={true} className="fediverse-sidebar-table">
<tbody>
<tr>
<td>Version</td>
<td>{<Code>{version}</Code> || "Unknown"}</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>
<td>Users</td>
<td>{userCount || "Unknown"}</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>
)
}
<tr>
<td>Statuses</td>
<td>{statusCount || "Unknown"}</td>
</tr>
<tr>
<td>Known peers</td>
<td>{domainCount || "Unknown"}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr>
</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 renderNeighbors = () => {
if (!this.props.graph || !this.props.instanceName) {
return;
}
const edges = this.props.graph.edges.filter(e => [e.source, e.target].indexOf(this.props.instanceName!) > -1);
const neighbors: any[] = [];
edges.forEach(e => {
if (e.source === this.props.instanceName) {
neighbors.push({ neighbor: e.target, weight: e.size });
} else {
neighbors.push({ neighbor: e.source, weight: e.size });
}
});
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 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 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 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/@tao" target="_blank">
Message @tao to opt in</AnchorButton>}
/>
)
}
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 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."
/>
)
}
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 openInstanceLink = () => {
window.open("https://" + this.props.instanceName, "_blank");
}
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/@tao" target="_blank">
Message @tao to opt in
</AnchorButton>
}
/>
);
};
private selectInstance = (e: any) => {
this.props.selectAndLoadInstance(e.target.innerText);
}
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."
/>
);
};
private renderQuietInstanceState = () => {
return (
<NonIdealState
icon={IconNames.CLEAN}
title="No interactions"
description="Users on this instance have not publicly interacted with any other instances recently. "
/>
);
};
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,
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),
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
});
export const Sidebar = connect(mapStateToProps, mapDispatchToProps)(SidebarImpl);
export const Sidebar = connect(
mapStateToProps,
mapDispatchToProps
)(SidebarImpl);

View File

@ -0,0 +1,83 @@
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react";
import { Page } from "../Page";
export const AboutScreen: React.FC = () => (
<Page>
<H1>About</H1>
<p className={Classes.RUNNING_TEXT}>
fediverse.space is a tool to visualize networks and communities on the{" "}
<a href="https://en.wikipedia.org/wiki/Fediverse" target="_blank">
fediverse
</a>
. It works by crawling every instance it can find and aggregating statistics on communication between these.
</p>
<H2>FAQ</H2>
<H4>Why can't I see details about my instance?</H4>
<p className={Classes.RUNNING_TEXT}>
Currently, fediverse.space only supports Mastodon and Pleroma instances. In addition, instances with 10 or fewer
users won't be scraped -- it's a tool for understanding communities, not individuals.
</p>
<H4>
When is <Code>$OTHER_ACTIVITYPUB_SERVER</Code> going to be added?
</H4>
<p className={Classes.RUNNING_TEXT}>
Check out{" "}
<a href="https://gitlab.com/taobojlen/fediverse.space/issues/24" target="_blank">
this GitLab issue
</a>
.
</p>
<H4>How do I add my personal instance?</H4>
<p className={Classes.RUNNING_TEXT}>
Send a DM to{" "}
<a href="https://cursed.technology/@fediversespace" target="_blank">
@fediversespace
</a>{" "}
on Mastodon. Make sure to send it from the account that's listed as the instance admin.
</p>
<H4>How do you calculate the strength of relationships between instances?</H4>
<p className={Classes.RUNNING_TEXT}>
fediverse.space scrapes the last 5000 statuses from within the last month on the public timeline of each instance.
It looks at the ratio of
<Code>mentions of an instance / total statuses</Code>. It uses a ratio rather than an absolute number of mentions
to reflect that smaller instances can play a large role in a community.
</p>
<H2>Credits</H2>
<p className={Classes.RUNNING_TEXT}>
This site is inspired by several other sites in the same vein:
<ul className={Classes.LIST}>
<li>
<a href="https://the-federation.info/" target="_blank">
the-federation.info
</a>
</li>
<li>
<a href="http://fediverse.network/" target="_blank">
fediverse.network
</a>
</li>
<li>
<a href="https://lucahammer.at/vis/fediverse/2018-08-30-mastoverse_hashtags/" target="_blank">
Mastodon hashtag network
</a>
{" by "}
<a href="https://vis.social/web/statuses/100634284168959187" target="_blank">
@Luca@vis.social
</a>
</li>
</ul>
The source code for fediverse.space is available on{" "}
<a href="https://gitlab.com/taobojlen/fediverse.space" target="_blank">
GitLab
</a>
; issues and pull requests are welcome!
</p>
</Page>
);

View File

@ -0,0 +1,79 @@
import * as React from "react";
import { connect } from "react-redux";
import { Dispatch } from "redux";
import { NonIdealState, Spinner } from "@blueprintjs/core";
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";
interface IGraphScreenProps {
graph?: IGraph;
instances?: IInstance[];
isLoadingGraph: boolean;
isLoadingInstances: boolean;
graphLoadError: boolean;
fetchInstances: () => void;
fetchGraph: () => void;
}
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>;
}
public componentDidMount() {
this.load();
}
public componentDidUpdate() {
this.load();
}
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 graphState = () => {
const content = this.props.graphLoadError ? <ErrorState /> : <Graph />;
return (
<div>
<Sidebar />
{content}
</div>
);
};
private loadingState = (title?: string) => {
return <NonIdealState icon={<Spinner />} title={title || "Loading..."} />;
};
}
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 mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
fetchInstances: () => dispatch(fetchInstances() as any)
});
export const GraphScreen = connect(
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);

View File

@ -1,19 +1,13 @@
html, body {
html,
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,Icons16,sans-serif;
}
.fediverse-heading-icon {
margin-right: 8px;
}
.fediverse-about-dialog {
top: 100px;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue,
Icons16, sans-serif;
}
.fediverse-instance-search-popover {
@ -33,7 +27,7 @@ html, body {
overflow: scroll;
overflow-x: hidden;
transition-property: all;
transition-duration: .5s;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
@ -44,10 +38,10 @@ html, body {
.fediverse-sidebar-toggle-button {
position: absolute;
top: 50px;
right:400px;
right: 400px;
z-index: 20;
transition-property: all;
transition-duration: .5s;
transition-duration: 0.5s;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
}
@ -67,4 +61,4 @@ html, body {
.fediverse-sidebar-toggle-button {
right: 25%;
}
}
}

View File

@ -1,19 +1,19 @@
import '../node_modules/@blueprintjs/core/lib/css/blueprint.css';
import '../node_modules/@blueprintjs/icons/lib/css/blueprint-icons.css';
import '../node_modules/@blueprintjs/select/lib/css/blueprint-select.css';
import '../node_modules/normalize.css/normalize.css';
import './index.css';
import "../node_modules/@blueprintjs/core/lib/css/blueprint.css";
import "../node_modules/@blueprintjs/icons/lib/css/blueprint-icons.css";
import "../node_modules/@blueprintjs/select/lib/css/blueprint-select.css";
import "../node_modules/normalize.css/normalize.css";
import "./index.css";
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { applyMiddleware, compose, createStore } from 'redux';
import thunk from 'redux-thunk';
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Provider } from "react-redux";
import { applyMiddleware, compose, createStore } from "redux";
import thunk from "redux-thunk";
import { FocusStyleManager } from '@blueprintjs/core';
import { FocusStyleManager } from "@blueprintjs/core";
import { App } from './App';
import { rootReducer } from './redux/reducers';
import { AppRouter } from "./AppRouter";
import { rootReducer } from "./redux/reducers";
// https://blueprintjs.com/docs/#core/accessibility.focus-management
FocusStyleManager.onlyShowFocusOnTabs();
@ -21,13 +21,11 @@ FocusStyleManager.onlyShowFocusOnTabs();
// Initialize redux
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(rootReducer, composeEnhancers(
applyMiddleware(thunk)
));
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));
ReactDOM.render(
<Provider store={store}>
<App />
<AppRouter />
</Provider>,
document.getElementById('root') as HTMLElement
document.getElementById("root") as HTMLElement
);

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -1,101 +1,100 @@
import { Dispatch } from 'redux';
import { Dispatch } from "redux";
import { getFromApi } from '../util';
import { ActionType, IGraph, IInstance, IInstanceDetails } from './types';
import { getFromApi } from "../util";
import { ActionType, IGraph, IInstance, IInstanceDetails } from "./types";
// selectInstance and deselectInstance are not exported since we only call them from selectAndLoadInstance()
const selectInstance = (instanceName: string) => {
return {
payload: instanceName,
type: ActionType.SELECT_INSTANCE,
}
}
return {
payload: instanceName,
type: ActionType.SELECT_INSTANCE
};
};
const deselectInstance = () => {
return {
type: ActionType.DESELECT_INSTANCE,
}
}
return {
type: ActionType.DESELECT_INSTANCE
};
};
export const requestInstances = () => {
return {
type: ActionType.REQUEST_INSTANCES,
}
}
return {
type: ActionType.REQUEST_INSTANCES
};
};
export const receiveInstances = (instances: IInstance[]) => {
return {
payload: instances,
type: ActionType.RECEIVE_INSTANCES,
}
}
return {
payload: instances,
type: ActionType.RECEIVE_INSTANCES
};
};
export const requestGraph = () => {
return {
type: ActionType.REQUEST_GRAPH,
}
}
return {
type: ActionType.REQUEST_GRAPH
};
};
export const receiveGraph = (graph: IGraph) => {
return {
payload: graph,
type: ActionType.RECEIVE_GRAPH,
}
}
return {
payload: graph,
type: ActionType.RECEIVE_GRAPH
};
};
const graphLoadFailed = () => {
return {
type: ActionType.GRAPH_LOAD_ERROR,
}
}
return {
type: ActionType.GRAPH_LOAD_ERROR
};
};
const instanceLoadFailed = () => {
return {
type: ActionType.INSTANCE_LOAD_ERROR,
}
}
return {
type: ActionType.INSTANCE_LOAD_ERROR
};
};
export const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
return {
payload: instanceDetails,
type: ActionType.RECEIVE_INSTANCE_DETAILS,
}
}
return {
payload: instanceDetails,
type: ActionType.RECEIVE_INSTANCE_DETAILS
};
};
/** 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()));
}
}
return (dispatch: Dispatch) => {
dispatch(requestInstances());
return getFromApi("instances")
.then(instances => dispatch(receiveInstances(instances)))
.catch(e => dispatch(graphLoadFailed()));
};
};
export const selectAndLoadInstance = (instanceName: string) => {
return (dispatch: Dispatch) => {
if (!instanceName) {
dispatch(deselectInstance());
return;
}
dispatch(selectInstance(instanceName));
return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details)))
.catch(e => dispatch(instanceLoadFailed()));
return (dispatch: Dispatch) => {
if (!instanceName) {
dispatch(deselectInstance());
return;
}
}
dispatch(selectInstance(instanceName));
return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details)))
.catch(e => dispatch(instanceLoadFailed()));
};
};
export const fetchGraph = () => {
return (dispatch: Dispatch) => {
dispatch(requestGraph());
return Promise.all([getFromApi("graph/edges"), getFromApi("graph/nodes")])
.then(responses => {
return {
edges: responses[0],
nodes: responses[1],
};
})
.then(graph => dispatch(receiveGraph(graph)))
.catch(e => dispatch(graphLoadFailed()));
}
}
return (dispatch: Dispatch) => {
dispatch(requestGraph());
return Promise.all([getFromApi("graph/edges"), getFromApi("graph/nodes")])
.then(responses => {
return {
edges: responses[0],
nodes: responses[1]
};
})
.then(graph => dispatch(receiveGraph(graph)))
.catch(e => dispatch(graphLoadFailed()));
};
};

View File

@ -1,87 +1,87 @@
import { combineReducers } from 'redux';
import { combineReducers } from "redux";
import { ActionType, IAction, ICurrentInstanceState, IDataState } from './types';
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types";
const initialDataState = {
error: false,
isLoadingGraph: false,
isLoadingInstances: 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,
isLoadingGraph: true,
};
case ActionType.RECEIVE_GRAPH:
return {
...state,
graph: action.payload,
isLoadingGraph: false,
};
case ActionType.GRAPH_LOAD_ERROR:
return {
...state,
error: true,
isLoadingGraph: false,
isLoadingInstances: false,
};
default:
return state;
}
}
const initialCurrentInstanceState = {
currentInstanceDetails: null,
currentInstanceName: null,
error: false,
isLoadingInstanceDetails: false,
error: false,
isLoadingGraph: false,
isLoadingInstances: 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,
isLoadingGraph: true
};
case ActionType.RECEIVE_GRAPH:
return {
...state,
graph: action.payload,
isLoadingGraph: false
};
case ActionType.GRAPH_LOAD_ERROR:
return {
...state,
error: true,
isLoadingGraph: false,
isLoadingInstances: false
};
default:
return state;
}
};
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:
return {
...state,
currentInstanceName: action.payload,
isLoadingInstanceDetails: true
};
case ActionType.RECEIVE_INSTANCE_DETAILS:
return {
...state,
currentInstanceDetails: action.payload,
isLoadingInstanceDetails: false
};
case ActionType.DESELECT_INSTANCE:
return {
...state,
currentInstanceDetails: null,
currentInstanceName: null
};
case ActionType.INSTANCE_LOAD_ERROR:
return {
...state,
error: true,
isLoadingInstanceDetails: false
};
default:
return state;
}
};
const currentInstance = (state = initialCurrentInstanceState , action: IAction): ICurrentInstanceState => {
switch (action.type) {
case ActionType.SELECT_INSTANCE:
return {
...state,
currentInstanceName: action.payload,
isLoadingInstanceDetails: true,
};
case ActionType.RECEIVE_INSTANCE_DETAILS:
return {
...state,
currentInstanceDetails: action.payload,
isLoadingInstanceDetails: false,
}
case ActionType.DESELECT_INSTANCE:
return {
...state,
currentInstanceDetails: null,
currentInstanceName: null,
}
case ActionType.INSTANCE_LOAD_ERROR:
return {
...state,
error: true,
isLoadingInstanceDetails: false,
};
default:
return state;
}
}
export const rootReducer = combineReducers({
currentInstance,
data,
})
currentInstance,
data
});

View File

@ -1,76 +1,76 @@
export enum ActionType {
SELECT_INSTANCE = 'SELECT_INSTANCE',
REQUEST_INSTANCES = 'REQUEST_INSTANCES',
RECEIVE_INSTANCES = 'RECEIVE_INSTANCES',
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'
SELECT_INSTANCE = "SELECT_INSTANCE",
REQUEST_INSTANCES = "REQUEST_INSTANCES",
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
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"
}
export interface IAction {
type: ActionType,
payload: any,
type: ActionType;
payload: any;
}
export interface IInstance {
name: string,
numUsers?: number,
name: string;
numUsers?: number;
}
export interface IInstanceDetails {
name: string;
peers?: IInstance[];
description?: string;
domainCount?: number;
statusCount?: number;
userCount?: number;
version?: string;
lastUpdated?: string;
status: string;
name: string;
peers?: IInstance[];
description?: string;
domainCount?: number;
statusCount?: number;
userCount?: number;
version?: string;
lastUpdated?: string;
status: string;
}
interface IGraphNode {
id: string;
label: string;
x: number;
y: number;
size?: number;
color?: string;
id: string;
label: string;
x: number;
y: number;
size?: number;
color?: string;
}
interface IGraphEdge {
source: string;
target: string;
id?: string;
size?: number;
source: string;
target: string;
id?: string;
size?: number;
}
export interface IGraph {
nodes: IGraphNode[];
edges: IGraphEdge[];
nodes: IGraphNode[];
edges: IGraphEdge[];
}
// Redux state
export interface ICurrentInstanceState {
currentInstanceDetails: IInstanceDetails | null,
currentInstanceName: string | null,
isLoadingInstanceDetails: boolean,
error: boolean,
currentInstanceDetails: IInstanceDetails | null;
currentInstanceName: string | null;
isLoadingInstanceDetails: boolean;
error: boolean;
}
export interface IDataState {
instances?: IInstance[],
graph?: IGraph,
isLoadingInstances: boolean,
isLoadingGraph: boolean,
error: boolean,
instances?: IInstance[];
graph?: IGraph;
isLoadingInstances: boolean;
isLoadingGraph: boolean;
error: boolean;
}
export interface IAppState {
currentInstance: ICurrentInstanceState;
data: IDataState,
currentInstance: ICurrentInstanceState;
data: IDataState;
}

View File

@ -1,30 +1,37 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "build",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom"],
"sourceMap": true,
"allowJs": true,
"jsx": "react",
"moduleResolution": "node",
"rootDir": "src",
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"lib": [
"esnext",
"dom",
],
"module": "esnext",
"moduleResolution": "node",
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"outDir": "build",
"rootDir": "src",
"sourceMap": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"noUnusedLocals": true
"target": "es5",
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"exclude": [
"node_modules",
"build",
"scripts",
"acceptance-tests",
"webpack",
"jest",
"src/setupTests.ts"
"build"
],
"include": [
"src"
]
}

View File

@ -1,3 +0,0 @@
{
"extends": "./tsconfig.json"
}

View File

@ -1,6 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs"
}
}

View File

@ -1,10 +1,12 @@
{
"extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
"linterOptions": {
"exclude": [
"config/**/*.js",
"node_modules/**/*.ts",
"coverage/lcov-report/*.js"
]
}
"extends": [
"tslint:recommended",
"tslint-eslint-rules",
"tslint-react",
"@blueprintjs/tslint-config/blueprint-rules",
"tslint-config-prettier"
],
"exclude": [
"**/*.css"
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 140 KiB