Compare commits

...

50 Commits

Author SHA1 Message Date
Inex Code f3cb4ce73c Revert to fediverse.space domain 2021-09-18 18:16:21 +03:00
Inex Code 8fc4ebbcc5 Merge branch 'master' of github.com:innereq/index.community 2021-07-30 23:06:15 +03:00
Inex Code d7cc4d1d88 Switch to fedidb from fediverse.network 2021-07-30 23:05:25 +03:00
Inex Code a2fe689cab
Add yarn lint 2021-07-30 21:58:22 +03:00
Inex Code 950e03fc35 Linting 2021-07-30 21:50:14 +03:00
Inex Code fb9ed11b5d
Fix OTP version 2021-07-30 21:23:04 +03:00
Inex Code 6177cc4d43
Add Elixir CI 2021-07-30 20:26:17 +03:00
Inex Code dde5fa4896 Update readme 2021-07-30 20:02:34 +03:00
Inex Code 428af93950 Merge pull request 'Forking out' (#1) from dev-tests into master
Reviewed-on: #1
2021-07-30 19:58:56 +03:00
Inex Code 1c5faa1ee1 Basic Smithereen support 2021-07-30 19:54:37 +03:00
Inex Code 88f1c54f8e Forking out 2021-07-30 19:53:51 +03:00
Inex Code e127ac3596 some tests 2021-01-17 12:47:23 +03:00
Tao Bojlén 2e87b53024
fix #104 2020-10-13 17:26:26 +01:00
Tao Bojlén 0dd39e6984
allow data: images in CSP 2020-10-11 11:39:41 +01:00
Tao Bojlén 15ad5f1615
add link to personal website on about page 2020-10-11 11:39:20 +01:00
Tao Bojlén 153af37ccf
release 2.9.5 2020-10-11 11:32:15 +01:00
Tao Bojlén 5f8bd7f891
add debug config 2020-10-11 11:29:36 +01:00
Tao Bojlén e1d48e70c4
fix crawler missing APIs due to dep update 2020-10-11 11:29:22 +01:00
Tao Bojlén db04c3a67a release 2.9.4 2020-10-09 20:36:31 +01:00
Tao Bojlén 55994be8bc prepare for 2.9.3 2020-10-09 20:10:18 +01:00
Tao Bojlén 4691b8dfd7 allow plausible analytics in CSP 2020-10-09 20:08:58 +01:00
Tao Bojlén 3148578a7d
fix appsignal compilation errors by reverting to 1.x 2020-10-06 18:06:44 +01:00
Tao Bojlén 41f5461386
fix mix.lock appsignal error 2020-10-06 17:51:17 +01:00
Tao Bojlén a276eccba4
update to appsignal 2.0 2020-10-06 17:46:41 +01:00
Tao Bojlén 49b74c189b
use elixir 1.10 2020-10-06 16:53:05 +01:00
Tao Bojlén 6f75565061
update dependencies 2020-10-06 16:43:20 +01:00
Tao Bojlén c1d939e7b3 fix: frontend/package.json, frontend/yarn.lock & frontend/.snyk to reduce vulnerabilities
The following vulnerabilities are fixed with a Snyk patch:
- https://snyk.io/vuln/SNYK-JS-LODASH-567746
2020-10-06 14:16:23 +00:00
Tao Bojlén 2eb7ea98d1 fix: frontend/package.json, frontend/yarn.lock & frontend/.snyk to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-LODASH-567746
- https://snyk.io/vuln/SNYK-JS-LODASH-590103
- https://snyk.io/vuln/SNYK-JS-LODASH-608086
- https://snyk.io/vuln/SNYK-JS-NODEFETCH-674311
- https://snyk.io/vuln/SNYK-JS-SANITIZEHTML-585892
- https://snyk.io/vuln/SNYK-JS-SERIALIZEJAVASCRIPT-570062
- https://snyk.io/vuln/SNYK-JS-SOCKJS-575261
- https://snyk.io/vuln/SNYK-JS-YARGSPARSER-560381

The following vulnerabilities are fixed with a Snyk patch:
- https://snyk.io/vuln/SNYK-JS-LODASH-567746
2020-10-06 13:51:34 +00:00
Tao Bojlén f5daa648f7
release 2.9.2 2020-08-31 11:26:07 +01:00
Tao Bojlén 3cdc0dc49a
release 2.9.1 2020-08-31 11:22:43 +01:00
Tao Bojlén 15e0d982e9
add appsignal logo 2020-08-31 11:19:56 +01:00
Tao Bojlén e51edef22d
release 2.9.0 2020-06-19 11:54:43 +01:00
Tao Bojlén a8874c82ba
small a11y fixes 2020-06-19 11:46:43 +01:00
Tao Bojlén 9078c0315d
sanitize HTML in backend 2020-05-27 14:38:52 +01:00
Tao Bojlén 1c251866ff
add rate limiting 2020-05-27 14:08:04 +01:00
Tao Bojlén 3db98cbfa0 add security headers to netlify frontend 2020-05-19 15:19:01 +00:00
Tao Bojlén 94034ee538 only run tests when files have changed 2020-05-19 13:57:00 +00:00
Tao Bojlén dd2b43a9bf Revert "remove develop deployment"
This reverts commit 6e826f153a.
2020-05-19 13:45:38 +00:00
Tao Bojlén e532173322 update all dependencies 2020-05-19 13:45:27 +00:00
Tao Bojlén 9b9dec818a
add netlify deploy previews to CORS 2020-05-18 19:49:59 +01:00
Tao Bojlén d7b3cf8932
chore: remove dep scanning from gitlab-ci 2020-05-18 19:46:40 +01:00
Tao Bojlén 51daf3efae
accessibility improvements 2020-04-21 20:31:29 +01:00
Tao Bojlén 5d8b8c6dbd
update README with podman instructions 2020-04-21 20:30:34 +01:00
Tao Bojlén 8912ccc6f8
update deps 2020-04-21 20:30:16 +01:00
Tao Bror Bojlén 37c00908ec
double number of concurrent crawlers 2020-02-19 10:11:28 +00:00
Tao Bror Bojlén 41ac4ca9a8
bring back staging backend 2020-02-19 10:06:46 +00:00
Tao Bror Bojlén 99f2b247dc
release v2.8.6 2020-01-16 15:26:00 +00:00
Tao Bror Bojlén 4b332ba980
more crawlers more often 2020-01-16 15:24:36 +00:00
Tao Bror Bojlén 5b54e65827
Merge branch 'master' into develop 2019-12-25 22:04:16 +00:00
Tao Bror Bojlén b525a08521
Merge branch 'develop' 2019-11-21 20:05:57 +00:00
80 changed files with 42160 additions and 5285 deletions

43
.github/workflows/elixir.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Elixir CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Elixir
uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
with:
elixir-version: '1.12.2' # Define the elixir version [required]
otp-version: '24.0.4' # Define the OTP version [required]
- name: Restore dependencies cache
uses: actions/cache@v2
with:
working-directory: ./backend
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install dependencies
working-directory: ./backend
run: |
mix local.hex --force
mix local.rebar --force
mix deps.get
- name: Compile dependencies
working-directory: ./backend
run: mix deps.compile
- name: Run Credo
working-directory: ./backend
run: mix credo --strict
- name: Run sobelow
working-directory: ./backend
run: mix sobelow --config

37
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,37 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the master branch
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: Setup Node.js environment
uses: actions/setup-node@v2.3.0
- name: Setup deps
working-directory: ./frontend
run: yarn install
- name: Lint
working-directory: ./frontend
run: yarn lint

View File

@ -1,10 +1,3 @@
include:
template: Dependency-Scanning.gitlab-ci.yml
dependency_scanning:
only:
- schedules
test-frontend: test-frontend:
image: node:lts-alpine image: node:lts-alpine
stage: test stage: test
@ -20,16 +13,16 @@ test-frontend:
- frontend/.yarn - frontend/.yarn
only: only:
changes: changes:
- frontend/* - frontend/**/*
test-backend: test-backend:
stage: test stage: test
image: elixir:1.9 image: elixir:1.10
variables: variables:
MIX_ENV: test MIX_ENV: test
only: only:
changes: changes:
- backend/* - backend/**/*
before_script: before_script:
- cd backend - cd backend
script: script:
@ -67,4 +60,4 @@ deploy-gephi-production:
except: except:
- schedules - schedules
script: script:
- git-push dokku@api.fediverse.space:gephi master - git-push dokku@api.fediverse.space:gephi master

View File

@ -1,7 +1,6 @@
{ {
"recommendations": [ "recommendations": [
"jakebecker.elixir-ls", "jakebecker.elixir-ls",
"ms-vscode.vscode-typescript-tslint-plugin",
"kevinmcgowan.typescriptimport", "kevinmcgowan.typescriptimport",
"msjsdiag.debugger-for-chrome" "msjsdiag.debugger-for-chrome"
] ]

17
.vscode/launch.json vendored
View File

@ -5,12 +5,15 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"type": "chrome", "type": "mix_task",
"request": "launch", "request": "launch",
"name": "Launch Chrome", "name": "phx.server",
"url": "http://localhost:3000", "task": "phx.server",
"webRoot": "${workspaceFolder}/frontend/src", "taskArgs": [],
"runtimeExecutable": "/usr/bin/chromium-browser" "projectDir": "${workspaceRoot}/backend",
} "env": {
"SKIP_CRAWL": "1"
}
},
] ]
} }

View File

@ -19,21 +19,96 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### Security
## [2.9.6 - 2020-10-13]
### Added
- Added link to personal website on About page.
### Fixed
- Allow `data:` images in Netlify CSP.
- Fix inability to DM login links in releases (#104).
## [2.9.5 - 2020-10-11]
### Fixed
- Fixed crawler not finding API in some cases
## [2.9.4 - 2020-10-09]
### Fixed
- Fix CSP issues for Plausible analytics
## [2.9.3 - 2020-10-09]
### Added
- Allow Plausible privacy-preserving analytics in CSP
### Changed
- Update dependencies
- Update to Elixir 1.10
### Fixed
- Fixed CSP headers for data: images
## [2.9.2 - 2020-08-31]
### Removed
- Remove staging server
## [2.9.1 - 2020-08-31]
### Fixed
- Added AppSignal logo to "Special thanks" section
## [2.9.0 - 2020-06-19]
### Changed
- Bring back `develop` staging backup (now managed in DNS)
- Increase default number of concurrent crawlers to 100
- Accessibility improvements (according to axe review)
- Update dependencies
### Security
- Add rate limiting of auth endpoints
- Added security headers to netlify frontend
- Sanitize crawled HTML in the backend
## [2.8.6 - 2020-01-16]
### Changed
- Update default number of concurrent crawlers
- Crawl every 30 minutes instead of every hour
## [2.8.5 - 2019-12-25] ## [2.8.5 - 2019-12-25]
### Fixed ### Fixed
- Fixed link to Mastodon account
- Fixed link to Mastodon account
## [2.8.4 - 2019-11-21] ## [2.8.4 - 2019-11-21]
### Changed ### Changed
- Update links to @fediversespace Mastodon account - Update links to @fediversespace Mastodon account
### Removed ### Removed
- Remove staging backend server
- Remove staging backend server
### Fixed ### Fixed
- Fixed frontend crash when instance node missing
- Fixed frontend crash when instance node missing
## [2.8.3 - 2019-11-19] ## [2.8.3 - 2019-11-19]

View File

@ -1,12 +1,12 @@
# fediverse.space 🌐 # index.community 🌐
The map of the fediverse that you always wanted. The map of the fediverse that you always wanted.
Read the latest updates on Mastodon: [@fediversespace](https://mastodon.social/@fediversespace) Read the latest updates on Mastodon: [@indexCommunity](https://social.inex.rocks/@indexCommunity)
![A screenshot of fediverse.space](screenshot.png) ![A screenshot of fediverse.space](screenshot.png)
- [fediverse.space 🌐](#fediversespace-%f0%9f%8c%90) - [index.community 🌐](#indexcommunity-%f0%9f%8c%90)
- [Requirements](#requirements) - [Requirements](#requirements)
- [Running it](#running-it) - [Running it](#running-it)
- [Backend](#backend) - [Backend](#backend)
@ -20,9 +20,11 @@ Read the latest updates on Mastodon: [@fediversespace](https://mastodon.social/@
## Requirements ## Requirements
Though dockerized, backend development is easiest if you have the following installed. Note: examples here use `podman`. In most cases you should be able to replace `podman` with `docker`.
- For the scraper + API: Though containerized, backend development is easiest if you have the following installed.
- For the crawler + API:
- Elixir - Elixir
- Postgres - Postgres
- For laying out the graph: - For laying out the graph:
@ -36,9 +38,11 @@ Though dockerized, backend development is easiest if you have the following inst
### Backend ### Backend
- `cp example.env .env` and modify environment variables as required - `cp example.env .env` and modify environment variables as required
- `docker-compose build` - `podman build gephi && podman build phoenix`
- `docker-compose up -d phoenix` - `podman run --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:6.8.9`
- if you don't specify `phoenix`, it'll also start `gephi` which should only be run as a regular one-off job - If you've `run` this container previously, use `podman start elasticsearch`
- `podman run --name postgres -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 postgres:12`
- `podman-compose -f compose.backend-services.yml -f compose.phoenix.yml`
- Create the elasticsearch index: - Create the elasticsearch index:
- `iex -S mix app.start` - `iex -S mix app.start`
- `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)` - `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)`
@ -53,10 +57,6 @@ Though dockerized, backend development is easiest if you have the following inst
### Backend ### Backend
`./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it. `./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it.
If running in docker, this means you run
- `docker-compose build gephi`
- `docker-compose run gephi java -Xmx1g -jar build/libs/graphBuilder.jar` lays out the graph
### Frontend ### Frontend
@ -102,8 +102,6 @@ SHELL=/bin/bash
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar 0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
``` ```
10. (Optional) Set up caching with something like [dokku-nginx-cache](https://github.com/Aluxian/dokku-nginx-cache)
Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called
`instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future. `instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future.

View File

@ -1,7 +1,7 @@
FROM elixir:1.9.0-alpine as build FROM elixir:1.12-alpine as build
# install build dependencies # install build dependencies
RUN apk add --update git build-base RUN apk add --update git build-base
# prepare build dir # prepare build dir
RUN mkdir /app RUN mkdir /app
@ -37,7 +37,7 @@ RUN mix release
# prepare release image # prepare release image
FROM alpine:3.9 AS app FROM alpine:3.9 AS app
RUN apk add --update bash openssl RUN apk add --update bash openssl libstdc++ build-base
RUN mkdir /app RUN mkdir /app
WORKDIR /app WORKDIR /app

View File

@ -2,7 +2,7 @@
## Notes ## Notes
- This project requires Elixir >= 1.9. - This project requires Elixir >= 1.10.
- Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling) - Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling)
- This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/). - This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/).
@ -24,6 +24,8 @@ There are several environment variables you can set to configure how the crawler
- `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins. - `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins.
- Don't enter `https://`, this is added automatically. - Don't enter `https://`, this is added automatically.
- `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out. - `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out.
- `MASTODON_DOMAIN`. The domain (e.g. `mastodon.social`) that your bot login account is hosted on.
- `MASTODON_TOKEN`. The access token for the bot login account.
## Deployment ## Deployment

View File

@ -13,15 +13,13 @@ config :backend,
# Configures the endpoint # Configures the endpoint
config :backend, BackendWeb.Endpoint, config :backend, BackendWeb.Endpoint,
url: [host: "localhost"], url: [host: "localhost"],
secret_key_base: "XL4NKGBN9lZMrQbMEI1KJOlwAt8S7younVJl90TdAgzmwyapr3g7BRYSNYvX0sZ9", secret_key_base: System.get_env("SECRET_KEY_BASE"),
render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)], render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)]
pubsub: [name: Backend.PubSub, adapter: Phoenix.PubSub.PG2],
instrumenters: [Appsignal.Phoenix.Instrumenter]
config :backend, Backend.Repo, queue_target: 5000 config :backend, Backend.Repo, queue_target: 5000
config :backend, Backend.Elasticsearch.Cluster, config :backend, Backend.Elasticsearch.Cluster,
url: "http://localhost:9200", url: "http://elastic:9200",
api: Elasticsearch.API.HTTP, api: Elasticsearch.API.HTTP,
json_library: Jason json_library: Jason
@ -37,19 +35,22 @@ config :gollum,
# 24 hrs # 24 hrs
refresh_secs: 86_400, refresh_secs: 86_400,
lazy_refresh: true, lazy_refresh: true,
user_agent: "fediverse.space crawler" user_agent: "index.community crawler"
config :backend, Graph.Cache, config :backend, Graph.Cache,
# 1 hour # 1 hour
gc_interval: 3600 gc_interval: 3600
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
config :backend, Backend.Mailer, config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.Sendgrid, adapter: Swoosh.Adapters.SMTP,
api_key: System.get_env("SENDGRID_API_KEY") relay: System.get_env("MAILER_RELAY"),
username: System.get_env("MAILER_USERNAME"),
password: System.get_env("MAILER_PASSWORD"),
ssl: true,
tls: :always,
auth: :always,
port: 465
config :backend, Mastodon.Messenger, config :backend, Mastodon.Messenger,
domain: System.get_env("MASTODON_DOMAIN"), domain: System.get_env("MASTODON_DOMAIN"),
@ -59,8 +60,8 @@ config :backend, :crawler,
status_age_limit_days: 28, status_age_limit_days: 28,
status_count_limit: 5000, status_count_limit: 5000,
personal_instance_threshold: 10, personal_instance_threshold: 10,
crawl_interval_mins: 60, crawl_interval_mins: 30,
crawl_workers: 20, crawl_workers: 100,
blacklist: [ blacklist: [
# spam # spam
"gab.best", "gab.best",
@ -71,10 +72,9 @@ config :backend, :crawler,
# dummy instances used for pleroma CI # dummy instances used for pleroma CI
"pleroma.online" "pleroma.online"
], ],
user_agent: "fediverse.space crawler", user_agent: "index.community crawler",
require_bidirectional_mentions: false, require_bidirectional_mentions: false,
admin_phone: System.get_env("ADMIN_PHONE"), admin_phone: System.get_env("ADMIN_PHONE"),
twilio_phone: System.get_env("TWILIO_PHONE"),
admin_email: System.get_env("ADMIN_EMAIL") admin_email: System.get_env("ADMIN_EMAIL")
config :backend, Backend.Scheduler, config :backend, Backend.Scheduler,
@ -91,6 +91,10 @@ config :backend, Backend.Scheduler,
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}} {"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
] ]
config :phoenix, :template_engines,
eex: Appsignal.Phoenix.Template.EExEngine,
exs: Appsignal.Phoenix.Template.ExsEngine
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs" import_config "#{Mix.env()}.exs"

View File

@ -7,7 +7,7 @@ import Config
# watchers to your application. For example, we use it # watchers to your application. For example, we use it
# with webpack to recompile .js and .css sources. # with webpack to recompile .js and .css sources.
config :backend, BackendWeb.Endpoint, config :backend, BackendWeb.Endpoint,
http: [port: 4000], http: [port: 4001],
debug_errors: true, debug_errors: true,
code_reloader: true, code_reloader: true,
check_origin: false, check_origin: false,
@ -53,7 +53,7 @@ config :backend, Backend.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: "postgres",
database: "backend_dev", database: "backend_dev",
hostname: "localhost", hostname: "127.0.0.1:5435",
pool_size: 10 pool_size: 10
config :backend, :crawler, config :backend, :crawler,

View File

@ -19,7 +19,7 @@ config :backend, Backend.Elasticsearch.Cluster,
config :appsignal, :config, revision: System.get_env("GIT_REV") config :appsignal, :config, revision: System.get_env("GIT_REV")
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4001")
config :backend, BackendWeb.Endpoint, config :backend, BackendWeb.Endpoint,
http: [:inet6, port: port], http: [:inet6, port: port],
@ -28,16 +28,20 @@ config :backend, BackendWeb.Endpoint,
secret_key_base: System.get_env("SECRET_KEY_BASE"), secret_key_base: System.get_env("SECRET_KEY_BASE"),
server: true server: true
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
config :backend, :crawler, config :backend, :crawler,
admin_phone: System.get_env("ADMIN_PHONE"), admin_phone: System.get_env("ADMIN_PHONE"),
twilio_phone: System.get_env("TWILIO_PHONE"),
admin_email: System.get_env("ADMIN_EMAIL"), admin_email: System.get_env("ADMIN_EMAIL"),
frontend_domain: System.get_env("FRONTEND_DOMAIN") frontend_domain: System.get_env("FRONTEND_DOMAIN")
config :backend, Backend.Mailer, config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.Sendgrid, adapter: Swoosh.Adapters.SMTP,
api_key: System.get_env("SENDGRID_API_KEY") relay: System.get_env("MAILER_RELAY"),
username: System.get_env("MAILER_USERNAME"),
password: System.get_env("MAILER_PASSWORD"),
ssl: true,
auth: :always,
port: 465
config :backend, Mastodon.Messenger,
domain: System.get_env("MASTODON_DOMAIN"),
token: System.get_env("MASTODON_TOKEN")

View File

@ -0,0 +1,37 @@
version: "2"
networks:
space:
external: false
services:
server:
build: .
restart: unless-stopped
networks:
- space
volumes:
- /home/gitea/data:/data
depends_on:
- db
db:
image: postgres:12-alpine
restart: unless-stopped
environment:
- POSTGRES_PASSWORD: postgres
- POSTGRES_USER: postgres
networks:
- space
volumes:
- /var/lib/postgresql/data
elastic:
image: elasticsearch:6.8.9
restart: unless-stopped
environment:
- discovery.type: single-node
networks:
- space

View File

@ -7,6 +7,7 @@ defmodule Backend.Application do
import Backend.Util import Backend.Util
def start(_type, _args) do def start(_type, _args) do
:telemetry.attach( :telemetry.attach(
"appsignal-ecto", "appsignal-ecto",
[:backend, :repo, :query], [:backend, :repo, :query],

View File

@ -18,7 +18,7 @@ defmodule Backend.Crawler.ApiCrawler do
# {domain, type} e.g. {"gab.com", "reject"} # {domain, type} e.g. {"gab.com", "reject"}
@type federation_restriction :: {String.t(), String.t()} @type federation_restriction :: {String.t(), String.t()}
@type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial @type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial | :smithereen
defstruct [ defstruct [
:version, :version,

View File

@ -159,8 +159,8 @@ defmodule Backend.Crawler do
## Update the instance we crawled ## ## Update the instance we crawled ##
instance = %Instance{ instance = %Instance{
domain: domain, domain: domain,
description: result.description, description: HtmlSanitizeEx.basic_html(result.description),
version: result.version, version: HtmlSanitizeEx.basic_html(result.version),
user_count: result.user_count, user_count: result.user_count,
status_count: result.status_count, status_count: result.status_count,
type: instance_type, type: instance_type,

View File

@ -12,7 +12,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
@impl ApiCrawler @impl ApiCrawler
def is_instance_type?(domain, result) do def is_instance_type?(domain, result) do
# We might already know that this is a Pleroma instance from nodeinfo # We might already know that this is a Pleroma instance from nodeinfo
if result != nil and Map.get(result, :instance_type) == :pleroma do if result != nil and (Map.get(result, :instance_type) == :pleroma or Map.get(result, :instance_type) == :smithereen) do
true true
else else
case get_and_decode("https://#{domain}/api/v1/instance") do case get_and_decode("https://#{domain}/api/v1/instance") do
@ -230,6 +230,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
defp get_instance_type(instance_stats) do defp get_instance_type(instance_stats) do
cond do cond do
Map.get(instance_stats, "version") |> String.downcase() =~ "pleroma" -> :pleroma Map.get(instance_stats, "version") |> String.downcase() =~ "pleroma" -> :pleroma
Map.get(instance_stats, "version") |> String.downcase() =~ "smithereen" -> :smithereen
is_gab?(instance_stats) -> :gab is_gab?(instance_stats) -> :gab
true -> :mastodon true -> :mastodon
end end

View File

@ -26,7 +26,7 @@ defmodule Backend.Crawler.StaleInstanceManager do
case instance_count do case instance_count do
# Add m.s. as the seed and schedule the next add # Add m.s. as the seed and schedule the next add
0 -> 0 ->
add_to_queue("mastodon.social") add_to_queue("mastodon.ml")
schedule_add() schedule_add()
# Start immediately # Start immediately

View File

@ -14,7 +14,7 @@ defmodule Backend.Release do
] ]
# Ecto repos to start, if any # Ecto repos to start, if any
@repos Application.get_env(:backend, :ecto_repos, []) @repos Application.compile_env(:backend, :ecto_repos, [])
# Elasticsearch clusters to start # Elasticsearch clusters to start
@clusters [Backend.Elasticsearch.Cluster] @clusters [Backend.Elasticsearch.Cluster]
# Elasticsearch indexes to build # Elasticsearch indexes to build

View File

@ -3,7 +3,7 @@ defmodule Backend.Scheduler do
This module runs recurring tasks. This module runs recurring tasks.
""" """
use Quantum.Scheduler, otp_app: :backend use Quantum, otp_app: :backend
alias Backend.{Crawl, CrawlInteraction, Edge, FederationRestriction, Instance, Repo} alias Backend.{Crawl, CrawlInteraction, Edge, FederationRestriction, Instance, Repo}
alias Backend.Mailer.AdminEmail alias Backend.Mailer.AdminEmail
@ -280,7 +280,6 @@ defmodule Backend.Scheduler do
end).() end).()
Logger.info(message) Logger.info(message)
send_admin_sms(message)
AdminEmail.send("Potential spam", message) AdminEmail.send("Potential spam", message)
else else
Logger.debug("Did not find potential spam instances.") Logger.debug("Did not find potential spam instances.")

View File

@ -113,21 +113,6 @@ defmodule Backend.Util do
end) end)
end end
@doc """
Sends an SMS to the admin phone number if configured.
"""
def send_admin_sms(body) do
if get_config(:admin_phone) != nil and get_config(:twilio_phone) != nil do
ExTwilio.Message.create(
to: get_config(:admin_phone),
from: get_config(:twilio_phone),
body: body
)
else
Logger.info("Could not send SMS to admin; not configured.")
end
end
@spec clean_domain(String.t()) :: String.t() @spec clean_domain(String.t()) :: String.t()
def clean_domain(domain) do def clean_domain(domain) do
cleaned = cleaned =
@ -170,7 +155,9 @@ defmodule Backend.Util do
timeout: timeout timeout: timeout
) do ) do
{:ok, %{status_code: 200, body: body}} -> Jason.decode(body) {:ok, %{status_code: 200, body: body}} -> Jason.decode(body)
{:ok, _} -> {:error, %HTTPoison.Error{reason: "Non-200 response"}} {:ok, %{status_code: 401}} -> Jason.decode("[]")
{:ok, %{status_code: 404}} -> Jason.decode("[]")
{:ok, %{body: body}} -> {:error, %HTTPoison.Error{reason: "Non-200 response. Body: #{body}"}}
{:error, err} -> {:error, err} {:error, err} -> {:error, err}
end end
end end

View File

@ -46,7 +46,7 @@ defmodule BackendWeb.Endpoint do
) )
plug(Corsica, plug(Corsica,
origins: ["http://localhost:3000", ~r{^https?://(.*\.?)fediverse\.space$}], origins: ["http://localhost:3001", ~r{^https://(.*\.?)index\.community$}, ~r{^https://(.*\.?)fediverse\.space$}],
allow_headers: ["content-type", "token"] allow_headers: ["content-type", "token"]
) )

View File

@ -0,0 +1,53 @@
defmodule BackendWeb.RateLimiter do
@moduledoc """
Functions used to rate limit:
* all endpoints by IP/endpoint
* authentication endpoints by domain
"""
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_status: 2]
use Plug.Builder
def rate_limit(conn, options \\ []) do
case check_rate(conn, options) do
{:ok, _count} -> conn # Do nothing, allow execution to continue
{:error, _count} -> render_error(conn)
end
end
def rate_limit_authentication(conn, options \\ []) do
domain =
if Map.has_key?(conn.params, "id") do
Map.get(conn.params, "id")
else
Map.get(conn.params, "domain")
end
options = Keyword.put(options, :bucket_name, "authorization: #{domain}")
rate_limit(conn, options)
end
defp check_rate(conn, options) do
interval_milliseconds = options[:interval_seconds] * 1000
max_requests = options[:max_requests]
bucket_name = options[:bucket_name] || bucket_name(conn)
ExRated.check_rate(bucket_name, interval_milliseconds, max_requests)
end
# Bucket name should be a combination of ip address and request path, like so:
#
# "127.0.0.1:/api/v1/authorizations"
defp bucket_name(conn) do
path = Enum.join(conn.path_info, "/")
ip = conn.remote_ip |> Tuple.to_list |> Enum.join(".")
"#{ip}:#{path}"
end
defp render_error(conn) do
conn
|> put_status(:forbidden)
|> json(%{error: "Rate limit exceeded."})
|> halt # Stop execution of further plugs, return response now
end
end

View File

@ -1,8 +1,14 @@
defmodule BackendWeb.Router do defmodule BackendWeb.Router do
use BackendWeb, :router use BackendWeb, :router
import BackendWeb.RateLimiter
pipeline :api do pipeline :api do
plug(:accepts, ["json"]) plug(:accepts, ["json"])
plug(:rate_limit, max_requests: 5, interval_seconds: 10) # requests to the same endpoint
end
pipeline :api_admin do
plug(:rate_limit_authentication, max_requests: 5, interval_seconds: 60)
end end
scope "/api", BackendWeb do scope "/api", BackendWeb do
@ -12,8 +18,12 @@ defmodule BackendWeb.Router do
resources("/graph", GraphController, only: [:index, :show]) resources("/graph", GraphController, only: [:index, :show])
resources("/search", SearchController, only: [:index]) resources("/search", SearchController, only: [:index])
resources("/admin/login", AdminLoginController, only: [:show, :create]) scope "/admin" do
get "/admin", AdminController, :show pipe_through :api_admin
post "/admin", AdminController, :update
resources("/login", AdminLoginController, only: [:show, :create])
get "/", AdminController, :show
post "/", AdminController, :update
end
end end
end end

View File

@ -12,7 +12,7 @@ defmodule Backend.Mailer.AdminEmail do
if admin_email != nil do if admin_email != nil do
new() new()
|> to(admin_email) |> to(admin_email)
|> from("noreply@fediverse.space") |> from("noreply@index.community")
|> subject(subject) |> subject(subject)
|> text_body(body) |> text_body(body)
|> Backend.Mailer.deliver!() |> Backend.Mailer.deliver!()

View File

@ -16,8 +16,8 @@ defmodule Backend.Mailer.UserEmail do
new() new()
|> to(address) |> to(address)
|> from("noreply@fediverse.space") |> from("noreply@index.community")
|> subject("Login to fediverse.space") |> subject("Login to index.community")
|> text_body(body) |> text_body(body)
|> Backend.Mailer.deliver() |> Backend.Mailer.deliver()
end end

View File

@ -23,11 +23,11 @@ defmodule Backend.MixProject do
extra_applications: [ extra_applications: [
:logger, :logger,
:runtime_tools, :runtime_tools,
:mnesia,
:gollum, :gollum,
:ex_twilio,
:elasticsearch, :elasticsearch,
:appsignal :appsignal,
:swoosh,
:gen_smtp
] ]
] ]
end end
@ -41,33 +41,33 @@ defmodule Backend.MixProject do
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
defp deps do defp deps do
[ [
{:phoenix, "~> 1.4.3"}, {:phoenix, "~> 1.5"},
{:phoenix_pubsub, "~> 1.1"}, {:phoenix_pubsub, "~> 2.0"},
{:phoenix_ecto, "~> 4.0"}, {:phoenix_ecto, "~> 4.0"},
{:ecto_sql, "~> 3.0"}, {:ecto_sql, "~> 3.0"},
{:postgrex, ">= 0.0.0"}, {:postgrex, ">= 0.0.0"},
{:gettext, "~> 0.11"}, {:gettext, "~> 0.11"},
{:jason, "~> 1.0"}, {:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.0"}, {:plug_cowboy, "~> 2.1"},
{:httpoison, "~> 1.5"}, {:httpoison, "~> 1.7", override: true},
{:timex, "~> 3.5"}, {:timex, "~> 3.5"},
{:honeydew, "~> 1.4.3"}, {:honeydew, "~> 1.5.0"},
{:quantum, "~> 2.3"}, {:quantum, "~> 3.3"},
{:corsica, "~> 1.1.2"}, {:corsica, "~> 1.1.2"},
{:sobelow, "~> 0.8", only: [:dev, :test]}, {:sobelow, "~> 0.8", only: [:dev, :test]},
{:gollum, "~> 0.3.2"}, {:gollum, "~> 0.3.2"},
{:public_suffix, "~> 0.6.0"}, {:public_suffix, git: "https://github.com/axelson/publicsuffix-elixir"},
{:idna, "~> 5.1.2", override: true}, {:swoosh, "~> 1.0"},
{:swoosh, "~> 0.23.3"}, {:gen_smtp, "~> 1.1"},
{:ex_twilio, "~> 0.7.0"},
{:elasticsearch, "~> 1.0"}, {:elasticsearch, "~> 1.0"},
{:appsignal, "~> 1.10.1"}, {:appsignal, "~> 1.0"},
{:credo, "~> 1.1", only: [:dev, :test], runtime: false}, {:credo, "~> 1.1", only: [:dev, :test], runtime: false},
{:nebulex, "~> 1.1"}, {:nebulex, "~> 1.1"},
{:hunter, "~> 0.5.1"}, {:hunter, "~> 0.5.1"},
{:poison, "~> 4.0", override: true},
{:scrivener_ecto, "~> 2.2"}, {:scrivener_ecto, "~> 2.2"},
{:recase, "~> 0.6.0"} {:recase, "~> 0.7"},
{:ex_rated, "~> 2.0"},
{:html_sanitize_ex, "~> 1.4"}
] ]
end end

View File

@ -1,66 +1,66 @@
%{ %{
"appsignal": {:hex, :appsignal, "1.10.13", "d5df34ac7dc2d937510716f2089cc5f1d45b3f10f38225d19c35f31810b9266d", [:make, :mix], [{:decorator, "~> 1.2.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "appsignal": {:hex, :appsignal, "1.13.5", "153ebe929fae8f637d43bf66058efecbb4affc4037caa466d31a236cb3f2e788", [:make, :mix], [{:decorator, "~> 1.2.3 or ~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.9", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8b6847c0d7f8ad03523be0fa6fdd670679ad42d62e2a8b74e599eba0247096c"},
"artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"}, "corsica": {:hex, :corsica, "1.1.3", "5f1de40bc9285753aa03afbdd10c364dac79b2ddbf2ba9c5c9c47b397ec06f40", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8156b3a14a114a346262871333a931a1766b2597b56bf994fcfcb65443a348ad"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"corsica": {:hex, :corsica, "1.1.2", "5ad8b9dcbeeda4762d78a57c0c8c2f88e1eef8741508517c98cb79e0db1f107d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
"cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"}, "credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"},
"credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "crontab": {:hex, :crontab, "1.1.10", "dc9bb1f4299138d47bce38341f5dcbee0aa6c205e864fba7bc847f3b5cb48241", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1347d889d1a0eda997990876b4894359e34bfbbd688acbb0ba28a2795ca40685"},
"crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"},
"db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"}, "decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"},
"decorator": {:hex, :decorator, "1.2.4", "31dfff6143d37f0b68d0bffb3b9f18ace14fea54d4f1b5e4f86ead6f00d9ff6e", [:mix], [], "hexpm"}, "ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"},
"distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"}, "ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"},
"ecto": {:hex, :ecto, "3.2.2", "bb6d1dbcd7ef975b60637e63182e56f3d7d0b5dd9c46d4b9d6183a5c455d65d1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "elasticsearch": {:hex, :elasticsearch, "1.0.1", "8339538d90af6b280f10ecd02b1eae372f09373e629b336a13461babf7366495", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm", "83e7d8b8bee3e7e19a06ab4d357d24845ac1da894e79678227fd52c0b7f71867"},
"ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, "ex2ms": {:hex, :ex2ms, "1.6.0", "f39bbd9ff1b0f27b3f707bab2d167066dd8965e7df1149b962d94c74615d0e09", [:mix], [], "hexpm", "0d1ab5e08421af5cd69146efb408dbb1ff77f38a2f4df5f086f2512dc8cf65bf"},
"elasticsearch": {:hex, :elasticsearch, "1.0.0", "626d3fb8e7554d9c93eb18817ae2a3d22c2a4191cc903c4644b1334469b15374", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6.0", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm"}, "ex_rated": {:hex, :ex_rated, "2.0.1", "49b4c170039fc62fa93ea28df16e3586e98c2fe0aec10f75e6717fba8039637f", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "2f675b649f74028842ae3d1f0c5090f8a664682df98c82836db6f1d321eaa42a"},
"ex_twilio": {:hex, :ex_twilio, "0.7.0", "d7ce624ef4661311ae28c3e3aa060ecb66a9f4843184d7400c29072f7d3f5a4a", [:mix], [{:httpoison, ">= 0.9.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:inflex, "~> 1.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "1.1.1", "bf9303c31735100631b1d708d629e4c65944319d1143b5c9952054f4a1311d85", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "51bc50cc017efd4a4248cbc39ea30fb60efa7d4a49688986fafad84434ff9ab7"},
"gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"}, "gen_stage": {:hex, :gen_stage, "1.1.0", "dd0c0f8d2f3b993fdbd3d58e94abbe65380f4e78bdee3fa93d5618d7d14abe60", [:mix], [], "hexpm", "7f2b36a6d02f7ef2ba410733b540ec423af65ec9c99f3d1083da508aca3b9305"},
"gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm"}, "gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm", "39268eeaf4f0adb6fdebe4f8c36b10a277881ab2eee3419c9b6727759e2f5a5d"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
"honeydew": {:hex, :honeydew, "1.4.5", "03818730602274ef0119652d664b92ddf733256e857d29899ce6841e01345bd1", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "honeydew": {:hex, :honeydew, "1.5.0", "53088c1d87399efa5c0939adc8d32a9713b8fe6ce00a77c6769d2d363abac6bc", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "f71669e25f6a972e970ecbd79c34c4ad4b28369be78e4f8164fe8d0c5a674907"},
"httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.1", "e8a67da405fe9f0d1be121a40a60f70811192033a5b8d00a95dddd807f5e053e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "68d92656f47cd73598c45ad2394561f025c8c65d146001b955fd7b517858962a"},
"hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "209b2cca7e4d51d5ff7ee4a0ab6cdc4c6ad23ddd61c9e12ceeee6f7ffbeae9c8"},
"inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm"}, "hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"}, "inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
"jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"}, "joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, "jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"nebulex": {:hex, :nebulex, "1.1.0", "be45cc3a2b7d01eb7da05747d38072d336187d05796ad9ef2d9dad9be430f915", [:mix], [{:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"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"}, "mochiweb": {:hex, :mochiweb, "2.21.0", "3fe5c3403606726d7bc6dabbf36f9d634d5364ce7f33ce73442937fa54feec37", [:rebar3], [], "hexpm", "f848bfa1b75c32d56da9d2730245e34df4b39079c5d45d7b966b072ba53f8a13"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "nebulex": {:hex, :nebulex, "1.2.2", "5b2bb7420a103b2a4278f354c9bd239bc77cd3bbdeddcebc4cc1d6ee656f126c", [:mix], [{:decorator, "~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm", "6804ddd7660fd4010a5af5957316ab7471c2db003189dba79dc3dd7b3f0aabf6"},
"phoenix": {:hex, :phoenix, "1.4.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"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"}, "phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.3.0", "2c69a452c2e0ee8c93345ae1cdc1696ef4877ff9cbb15c305def41960c3c4ebf", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "0ac491924217550c8f42c81c1f390b5d81517d12ceaf9abf3e701156760a848e"},
"plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "plug": {:hex, :plug, "1.12.0", "39dc7f1ef8c46bb1bf6dd8f6a49f526c45b4b92ce553687fd885b559a46d0230", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5282c76e89efdf43f2e04bd268ca99d738039f9518137f02ff468cee3ba78096"},
"plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.1", "7cc96ff645158a94cf3ec9744464414f02287f832d6847079adfe0b58761cbd0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "107d0a5865fa92bcb48e631cc0729ae9ccfa0a9f9a1bd8f01acb513abf1c2d64"},
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
"public_suffix": {:hex, :public_suffix, "0.6.0", "100cfe86f13f9f6f0cf67e743b1b83c78dd1223a2c422fa03ebf4adff514cbc3", [:mix], [{:idna, ">= 1.2.0 and < 6.0.0", [hex: :idna, repo: "hexpm", optional: false]}], "hexpm"}, "postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"},
"quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"}, "public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir", "89372422ab8b433de508519ef474e39699fd11ca", []},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "quantum": {:hex, :quantum, "3.3.0", "e8f6b9479728774288c5f426b11a6e3e8f619f3c226163a7e18bccfe543b714d", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b83ef137ab3887e783b013418b5ce3e847d66b71c4ef0f233b0321c84b72f67"},
"recase": {:hex, :recase, "0.6.0", "1dd2dd2f4e06603b74977630e739f08b7fedbb9420cc14de353666c2fc8b99f4", [:mix], [], "hexpm"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm"}, "recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.2.0", "53d5f1ba28f35f17891cf526ee102f8f225b7024d1cdaf8984875467158c9c5e", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm"}, "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"shards": {:hex, :shards, "0.6.0", "678d292ad74a4598a872930f9b12251f43e97f6050287f1fb712fbfd3d282f75", [:make, :rebar3], [], "hexpm"}, "scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"sobelow": {:hex, :sobelow, "0.9.1", "0e3baeb03c2f98364a11dfb20bdad0790a2153aac2f07d3f8cdf7997c09dd649", [:mix], [], "hexpm"}, "shards": {:hex, :shards, "0.6.2", "e05d05537883220c3b8a8f9d40d5c8ba7ff6064c63ebb6b23046972f6863b2d1", [:make, :rebar3], [], "hexpm", "58afa3712f1f1256a2a15e39fa95b7cd758087aaa7a25beaf786daabd87890f0"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"}, "sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "swoosh": {:hex, :swoosh, "1.5.0", "2be4cfc1be10f2203d1854c85b18d8c7be0321445a782efd53ef0b2b88f03ce4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b53891359e3ddca263ece784051243de84c9244c421a0dee1bff1d52fc5ca420"},
"telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"}, "timex": {:hex, :timex, "3.7.5", "3eca56e23bfa4e0848f0b0a29a92fa20af251a975116c6d504966e8a90516dfd", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a15608dca680f2ef663d71c95842c67f0af08a0f3b1d00e17bbd22872e2874e4"},
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, "tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"vex": {:hex, :vex, "0.6.0", "4e79b396b2ec18cd909eed0450b19108d9631842598d46552dc05031100b7a56", [:mix], [], "hexpm"}, "vex": {:hex, :vex, "0.9.0", "613ea5eb3055662e7178b83e25b2df0975f68c3d8bb67c1645f0573e1a78d606", [:mix], [], "hexpm", "c69fff44d5c8aa3f1faee71bba1dcab05dd36364c5a629df8bb11751240c857f"},
} }

View File

@ -1,65 +0,0 @@
version: "3"
services:
db:
image: postgres
environment:
- DATABASE_URL
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- database_network
elasticsearch:
image: elasticsearch:6.8.1
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
networks:
- phoenix_network
- es_network
# Kibana is just for development, really
kibana:
image: kibana:6.8.1
networks:
- es_network
ports:
- "5601:5601"
# This is for running the occasional graph layout task. It's in docker-compose.yml so that it's built at the same time
# as everything else, but it should be run regularly with a cron job or similar.
gephi:
environment:
- DATABASE_URL
build: ./gephi
volumes:
- gradle-cache:/code/.gradle
depends_on:
- db
networks:
- database_network
phoenix:
build: ./backend
networks:
- database_network
- phoenix_network
depends_on:
- db
ports:
- "${PORT}:${PORT}"
environment:
- DATABASE_URL
- SECRET_KEY_BASE
- PORT
- BACKEND_HOSTNAME
volumes:
pgdata:
esdata:
gradle-cache:
networks:
database_network:
driver: bridge
phoenix_network:
driver: bridge
es_network:
driver: bridge

4
frontend/.eslintignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
build
coverage

24
frontend/.eslintrc.js Normal file
View File

@ -0,0 +1,24 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
plugins: ["@typescript-eslint", "prettier"],
extends: [
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier/@typescript-eslint",
"prettier",
],
rules: {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"react/prop-types": 0,
"@typescript-eslint/no-non-null-assertion": 0
},
};

3
frontend/.prettierrc.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
printWidth: 100
}

9982
frontend/.snyk Normal file

File diff suppressed because it is too large Load Diff

37
frontend/nginx.conf Normal file
View File

@ -0,0 +1,37 @@
server {
listen 80;
listen [::]:80;
server_name fediverse.space;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/javascript
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
root /website;
index index.html;
location / {
try_files $uri /index.html;
}
}

25236
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,11 +6,13 @@
"start": "NODE_ENV=development react-scripts start", "start": "NODE_ENV=development react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"typecheck": "tsc --noemit", "typecheck": "tsc --noemit",
"lint": "yarn typecheck && tslint -p tsconfig.json -c tslint.json \"src/**/*.{ts,tsx}\"", "lint": "yarn typecheck && yarn eslint src/ --ext .js,.jsx,.ts,.tsx",
"lint:fix": "yarn lint --fix", "lint:fix": "yarn lint --fix",
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"", "pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
"test": "yarn lint && react-scripts test --ci", "test": "yarn lint && react-scripts test --ci",
"eject": "react-scripts eject" "eject": "react-scripts eject",
"snyk-protect": "snyk protect",
"prepare": "yarn run snyk-protect"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
@ -20,67 +22,74 @@
"lint-staged": { "lint-staged": {
"src/**/*.{ts,tsx}": [ "src/**/*.{ts,tsx}": [
"yarn pretty", "yarn pretty",
"yarn lint:fix", "yarn lint:fix"
"git add"
] ]
}, },
"prettier": { "prettier": {
"printWidth": 120 "printWidth": 120
}, },
"dependencies": { "dependencies": {
"@blueprintjs/core": "^3.19.1", "@blueprintjs/core": "^3.33.0",
"@blueprintjs/icons": "^3.11.0", "@blueprintjs/icons": "^3.22.0",
"@blueprintjs/select": "^3.11.1", "@blueprintjs/select": "^3.14.2",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"connected-react-router": "^6.5.2", "connected-react-router": "^6.5.2",
"cross-fetch": "^3.0.4", "cross-fetch": "^3.0.6",
"cytoscape": "^3.11.0", "cytoscape": "^3.16.1",
"cytoscape-popper": "^1.0.4", "cytoscape-popper": "^1.0.7",
"inflection": "^1.12.0", "inflection": "^1.12.0",
"lodash": "^4.17.15", "lodash": "^4.17.20",
"moment": "^2.22.2", "moment": "^2.29.1",
"normalize.css": "^8.0.0", "normalize.css": "^8.0.0",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"react": "^16.10.2", "react": "^16.10.2",
"react-dom": "^16.10.2", "react-dom": "^16.10.2",
"react-redux": "^7.1.1", "react-redux": "^7.2.1",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.2.0",
"react-scripts": "^3.2.0", "react-sigma": "^1.2.35",
"react-sigma": "^1.2.30", "react-virtualized": "^9.22.2",
"react-virtualized": "^9.21.1",
"redux": "^4.0.4", "redux": "^4.0.4",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"sanitize-html": "^1.20.1", "sanitize-html": "^2.0.0",
"styled-components": "^4.4.0", "snyk": "^1.410.1",
"styled-components": "^5.2.0",
"tippy.js": "^4.3.5" "tippy.js": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@blueprintjs/tslint-config": "^1.9.0",
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.9",
"@types/cytoscape": "^3.8.3", "@types/cytoscape": "^3.14.7",
"@types/inflection": "^1.5.28", "@types/inflection": "^1.5.28",
"@types/jest": "^24.0.19", "@types/jest": "^26.0.14",
"@types/lodash": "^4.14.144", "@types/lodash": "^4.14.161",
"@types/node": "^12.7.12", "@types/node": "^14.11.5",
"@types/numeral": "^0.0.26", "@types/numeral": "^0.0.28",
"@types/react": "^16.9.6", "@types/react": "^16.9.51",
"@types/react-dom": "^16.9.2", "@types/react-axe": "^3.1.0",
"@types/react-redux": "^7.1.4", "@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.0", "@types/react-redux": "^7.1.9",
"@types/sanitize-html": "^1.20.2", "@types/react-router-dom": "^5.1.6",
"@types/styled-components": "4.1.19", "@types/sanitize-html": "^1.27.0",
"husky": "^3.0.9", "@types/styled-components": "5.1.3",
"lint-staged": "^9.4.2", "@typescript-eslint/eslint-plugin": "^2.24.0",
"react-axe": "^3.3.0", "@typescript-eslint/parser": "^2.34.0",
"tslint": "^5.20.0", "eslint-config-airbnb-typescript": "^7.2.1",
"tslint-config-security": "^1.16.0", "eslint-config-prettier": "^6.12.0",
"tslint-eslint-rules": "^5.4.0", "eslint-plugin-import": "^2.22.1",
"typescript": "^3.6.4" "eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.3",
"eslint-plugin-react-hooks": "^4.1.2",
"husky": "^4.3.0",
"lint-staged": "^10.4.0",
"prettier": "^2.1.2",
"react-scripts": "3.4.3",
"typescript": "^3.9.2"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",
"not dead", "not dead",
"not ie <= 11", "not ie <= 11",
"not op_mini all" "not op_mini all"
] ],
} "snyk": true
}

View File

@ -4,28 +4,30 @@ import { Classes } from "@blueprintjs/core";
import { ConnectedRouter } from "connected-react-router"; import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { Nav } from "./components/organisms/"; import { Nav } from "./components/organisms";
import { import {
AboutScreen, AboutScreen,
AdminScreen, AdminScreen,
GraphScreen, GraphScreen,
LoginScreen, LoginScreen,
TableScreen, TableScreen,
VerifyLoginScreen VerifyLoginScreen,
} from "./components/screens/"; } from "./components/screens";
import { history } from "./index"; import { history } from "./index";
const AppRouter: React.FC = () => ( const AppRouter: React.FC = () => (
<ConnectedRouter history={history}> <ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}> <div className={`${Classes.DARK} App`}>
<Nav /> <Nav />
<Route path="/instances" exact={true} component={TableScreen} /> <main role="main">
<Route path="/about" exact={true} component={AboutScreen} /> <Route path="/instances" exact component={TableScreen} />
<Route path="/admin/login" exact={true} component={LoginScreen} /> <Route path="/about" exact component={AboutScreen} />
<Route path="/admin/verify" exact={true} component={VerifyLoginScreen} /> <Route path="/admin/login" exact component={LoginScreen} />
<Route path="/admin" exact={true} component={AdminScreen} /> <Route path="/admin/verify" exact component={VerifyLoginScreen} />
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */} <Route path="/admin" exact component={AdminScreen} />
<GraphScreen /> {/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
<GraphScreen />
</main>
</div> </div>
</ConnectedRouter> </ConnectedRouter>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -1,30 +1 @@
<?xml version="1.0" encoding="UTF-8"?> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170.08 170.08"><defs><style>.cls-1{fill:#04246e;}.cls-2{fill:#fff;}</style></defs><title>square-mark-white</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" width="170.08" height="170.08"/><path class="cls-2" d="M127.34,58c-8.4,0-14.41,7.4-20.6,15.65-3.84-17.51-8.11-35-21.7-35s-17.86,17.5-21.71,35C57.15,65.37,51.13,58,42.74,58c-5.35,0-14.39,3.63-14.39,17.25L28.41,100c0,13.36,7.14,16.64,11.42,17.76h0c7.39,1.91,25.17,3.69,45.18,3.69s37.8-1.78,45.18-3.69h0c4.28-1.12,11.42-4.4,11.42-17.76l.06-24.79c0-13.62-9-17.25-14.39-17.25M85,46.33c8.15,0,11.24,16.06,15.57,35.33C95.86,87.5,90.83,92.21,85,92.21S74.21,87.5,69.47,81.66c4-18,7.17-35.33,15.57-35.33M36.13,100l-.06-24.79c0-8.6,4.67-9.53,6.67-9.53,5.92,0,12.28,9.88,18.36,17.83-4.29,18-8.83,29-19.56,26.72-2.78-.77-5.41-2.53-5.41-10.23m21,12.53c4.75-5.16,7.78-13.21,10.08-21.73,5,5.19,10.69,9.13,17.8,9.13S97.86,96,102.83,90.81c2.31,8.52,5.33,16.57,10.07,21.73-8.29.75-18.28,1.21-27.86,1.21s-19.59-.46-27.88-1.21M134,100c0,7.7-2.63,9.46-5.42,10.23-10.73,2.27-15.26-8.72-19.56-26.72,6.08-7.94,12.45-17.83,18.37-17.83,2,0,6.67.93,6.67,9.53Z"/></g></g></svg>
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 603.8 104.9" enable-background="new 0 0 603.8 104.9" xml:space="preserve">
<g>
<polygon fill="#4988A2" points="481.8,16.9 481.8,80.7 561.3,80.7 "/>
<polygon fill="#7A4198" points="538.3,80.7 602.1,7.4 602.1,80.7 "/>
<polygon fill="#478B60" points="481.9,80.7 513.5,1.1 561,80.7 "/>
<polygon fill="#135F66" points="488.3,80.7 515.3,43.9 547.7,69.9 538.3,80.7 "/>
<polygon fill="#2B7B82" points="488.3,80.7 481.9,80.7 501.1,32.4 515.3,43.9 "/>
<polygon fill="#194A7F" points="547.7,69.9 538.3,80.7 561.1,80.7 "/>
<polyline fill="#5E2B7C" points="551.7,65.3 547.7,69.9 561.1,80.7 551.7,65.3 "/>
<polygon fill="#7A2980" points="602.1,80.7 561.1,80.7 551.7,65.3 575.6,37.8 "/>
<polygon fill="#456630" points="515.3,43.9 528.6,26.4 551.7,65.3 547.7,69.9 "/>
<polygon fill="#E77A45" points="528.6,26.4 535.4,16.9 551.3,39.2 566.9,23.9 575.6,37.8 551.7,65.3 "/>
</g>
<g>
<path fill="#6F8087" d="M61.7,80.7H49.5l-6.9-22.6H19.1l-6.6,22.6H0.5L24,4h14.2L61.7,80.7z M40.6,49.2l-6-20 c-1.4-5-2.7-10.4-3.8-15.1h-0.2c-1.1,4.7-2.3,10.3-3.6,15l-6.1,20.1H40.6z"/>
<path fill="#6F8087" d="M82.3,33.9c3.8-6.2,9.8-9.6,17.5-9.6c12.4,0,21.8,11,21.8,28.2c0,20.3-11.7,29.4-23.4,29.4 c-6.6,0-12-3.1-14.7-7.6h-0.2v28.9H71.7V43.3c0-7.2-0.2-12.9-0.4-17.8h10.2l0.6,8.4H82.3z M83.3,58.4c0,9.9,6.8,14.1,12.5,14.1 c9,0,13.9-8.3,13.9-19.8c0-10.5-4.7-19.2-13.6-19.2c-6.9,0-12.8,6.5-12.8,14.6V58.4z"/>
<path fill="#6F8087" d="M143.7,33.9c3.8-6.2,9.8-9.6,17.5-9.6c12.4,0,21.8,11,21.8,28.2c0,20.3-11.7,29.4-23.4,29.4 c-6.6,0-12-3.1-14.7-7.6h-0.2v28.9h-11.6V43.3c0-7.2-0.2-12.9-0.4-17.8h10.2l0.6,8.4H143.7z M144.7,58.4c0,9.9,6.8,14.1,12.5,14.1 c9,0,13.9-8.3,13.9-19.8c0-10.5-4.7-19.2-13.6-19.2c-6.9,0-12.8,6.5-12.8,14.6V58.4z"/>
<g>
<path fill="#404D5C" d="M230.9,15.9c-2.5-1.4-7.3-3.4-13.6-3.4c-9.1,0-12.9,5.3-12.9,10.2c0,6.5,4.3,9.7,14,13.9 c12.3,5.4,18.2,12.1,18.2,22.7c0,12.8-9.5,22.5-26.8,22.5c-7.2,0-14.8-2.1-18.5-4.6l2.6-9.7C198,70,204.3,72,210.6,72 c9.1,0,14.2-4.7,14.2-11.6c0-6.5-3.9-10.5-13-14.3c-11.4-4.6-19.1-11.5-19.1-22c0-12.1,9.7-21.3,25-21.3c7.3,0,12.8,1.8,16.1,3.5 L230.9,15.9z"/>
<path fill="#404D5C" d="M254.3,17.7c-4,0-6.7-3-6.7-6.7c0-3.9,2.8-6.8,6.8-6.8c4,0,6.7,2.9,6.7,6.8 C261.1,14.7,258.5,17.7,254.3,17.7z M260.1,80.7h-11.6V25.4h11.6V80.7z"/>
<path fill="#404D5C" d="M320.4,25.5c-0.3,3.9-0.5,8.5-0.5,15.9v31.5c0,11-0.9,31.4-27.1,31.4c-6.4,0-13.1-1.4-17.4-4l2.6-9 c3.4,2,8.7,3.9,14.9,3.9c9,0,15.4-5,15.4-17.5v-5.3h-0.2c-2.8,4.7-8.2,8.1-15.3,8.1c-13,0-22-11.5-22-27 c0-18.7,11.3-29.2,23.4-29.2c8.2,0,12.8,4.2,15.1,8.6h0.2l0.5-7.4H320.4z M308.2,46.8c0-7.3-4.8-13.3-11.9-13.3 c-8,0-13.6,7.6-13.6,19.4c0,10.8,4.9,18.5,13.5,18.5c6,0,12-4.6,12-13.8V46.8z"/>
<path fill="#404D5C" d="M379.5,80.7h-11.6V48.3c0-7.8-2.6-14.4-10.5-14.4c-5.7,0-11.8,4.7-11.8,13.5v33.3h-11.6V41 c0-6.1-0.2-10.8-0.4-15.5h10.1l0.6,8.2h0.3c2.6-4.7,8.4-9.4,16.7-9.4c8.6,0,18.2,5.6,18.2,22.7V80.7z"/>
<path fill="#404D5C" d="M432,67.6c0,4.7,0.1,9.5,0.8,13.1h-10.5l-0.8-6.4h-0.3c-3.1,4.3-8.5,7.5-15.4,7.5 c-10.3,0-16.2-7.6-16.2-16.1c0-13.7,11.9-20.6,30.9-20.6c0-4.1,0-12.3-11.1-12.3c-4.9,0-9.9,1.5-13.4,3.8l-2.4-7.7 c3.9-2.5,10.4-4.6,17.8-4.6c16.2,0,20.8,10.7,20.8,22.5V67.6z M420.7,52.9c-9.1,0-19.6,1.7-19.6,11.5c0,6.1,3.8,8.8,8.1,8.8 c6.3,0,11.5-4.8,11.5-11.2V52.9z"/>
<path fill="#404D5C" d="M457.8,80.7h-11.1V7.4l11.1-6.3V80.7z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -11,7 +11,7 @@ const FloatingCardElement = styled(Card)`
z-index: 2; z-index: 2;
`; `;
const FloatingCard: React.FC<ICardProps> = props => ( const FloatingCard: React.FC<ICardProps> = (props) => (
<FloatingCardRow> <FloatingCardRow>
<FloatingCardElement elevation={Elevation.ONE} {...props} /> <FloatingCardElement elevation={Elevation.ONE} {...props} />
</FloatingCardRow> </FloatingCardRow>

View File

@ -7,13 +7,13 @@ const StyledSwitch = styled(Switch)`
margin: 0; margin: 0;
`; `;
interface IGraphHideEdgesButtonProps { interface GraphHideEdgesButtonProps {
isShowingEdges: boolean; isShowingEdges: boolean;
toggleEdges: () => void; toggleEdges: () => void;
} }
const GraphHideEdgesButton: React.FC<IGraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => ( const GraphHideEdgesButton: React.FC<GraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
<FloatingCard> <FloatingCard>
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} /> <StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} tabIndex={-1} />
</FloatingCard> </FloatingCard>
); );
export default GraphHideEdgesButton; export default GraphHideEdgesButton;

View File

@ -6,9 +6,9 @@ import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { FloatingCard, InstanceType } from "."; import { FloatingCard, InstanceType } from ".";
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants"; import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
import { IColorScheme } from "../../types"; import { ColorScheme } from "../../types";
const ColorSchemeSelect = Select.ofType<IColorScheme>(); const ColorSchemeSelect = Select.ofType<ColorScheme>();
const StyledLi = styled.li` const StyledLi = styled.li`
margin-top: 2px; margin-top: 2px;
@ -27,12 +27,12 @@ const ColorBarContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-right: 10px; margin-right: 10px;
`; `;
interface IColorBarProps { interface ColorBarProps {
color: string; color: string;
} }
const ColorBar = styled.div<IColorBarProps>` const ColorBar = styled.div<ColorBarProps>`
width: 10px; width: 10px;
background-color: ${props => props.color}; background-color: ${(props) => props.color};
flex: 1; flex: 1;
`; `;
const TextContainer = styled.div` const TextContainer = styled.div`
@ -41,13 +41,46 @@ const TextContainer = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
interface IGraphKeyProps { const renderItem: ItemRenderer<ColorScheme> = (colorScheme, { handleClick, modifiers }) => {
current?: IColorScheme; if (!modifiers.matchesPredicate) {
colorSchemes: IColorScheme[]; return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map((v) => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
interface GraphKeyProps {
current?: ColorScheme;
colorSchemes: ColorScheme[];
ranges?: { [key: string]: [number, number] }; ranges?: { [key: string]: [number, number] };
onItemSelect: (colorScheme?: IColorScheme) => void; onItemSelect: (colorScheme?: ColorScheme) => void;
} }
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => { const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
const unsetColorScheme = () => { const unsetColorScheme = () => {
onItemSelect(undefined); onItemSelect(undefined);
}; };
@ -74,8 +107,9 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onI
text={(current && current.name) || "Select..."} text={(current && current.name) || "Select..."}
icon={IconNames.TINT} icon={IconNames.TINT}
rightIcon={IconNames.CARET_DOWN} rightIcon={IconNames.CARET_DOWN}
tabIndex={-1}
/> />
<Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} /> <Button icon={IconNames.SMALL_CROSS} minimal onClick={unsetColorScheme} disabled={!current} tabIndex={-1} />
</ColorSchemeSelect> </ColorSchemeSelect>
<br /> <br />
{!!current && !!key && ( {!!current && !!key && (
@ -88,38 +122,4 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onI
); );
}; };
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map(v => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
export default GraphKey; export default GraphKey;

View File

@ -2,12 +2,12 @@ import { Button } from "@blueprintjs/core";
import * as React from "react"; import * as React from "react";
import FloatingCard from "./FloatingCard"; import FloatingCard from "./FloatingCard";
interface IGraphResetButtonProps { interface GraphResetButtonProps {
onClick: () => void; onClick: () => void;
} }
const GraphResetButton: React.FC<IGraphResetButtonProps> = ({ onClick }) => ( const GraphResetButton: React.FC<GraphResetButtonProps> = ({ onClick }) => (
<FloatingCard> <FloatingCard>
<Button icon="compass" title="Reset graph view" onClick={onClick} /> <Button icon="compass" title="Reset graph view" onClick={onClick} tabIndex={-1} />
</FloatingCard> </FloatingCard>
); );
export default GraphResetButton; export default GraphResetButton;

View File

@ -5,7 +5,7 @@ import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
import { typeColorScheme } from "../../types"; import { typeColorScheme } from "../../types";
import { getTypeDisplayString } from "../../util"; import { getTypeDisplayString } from "../../util";
interface IInstanceTypeProps { interface InstanceTypeProps {
type: string; type: string;
colorAfterName?: boolean; colorAfterName?: boolean;
} }
@ -13,9 +13,9 @@ interface IInstanceTypeProps {
* By default, renders the color followed by the name of the instance type. * By default, renders the color followed by the name of the instance type.
* You can change this by passing `colorAfterName={true}`. * You can change this by passing `colorAfterName={true}`.
*/ */
const InstanceType: React.FC<IInstanceTypeProps> = ({ type, colorAfterName }) => { const InstanceType: React.FC<InstanceTypeProps> = ({ type, colorAfterName }) => {
const idx = typeColorScheme.values.indexOf(type); const idx = typeColorScheme.values.indexOf(type);
const name = " " + getTypeDisplayString(type); const name = ` ${getTypeDisplayString(type)}`;
return ( return (
<> <>
{!!colorAfterName && name} {!!colorAfterName && name}

View File

@ -11,19 +11,19 @@ const Backdrop = styled.div`
z-index: 3; z-index: 3;
`; `;
interface IContainerProps { interface ContainerProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const Container = styled.div<IContainerProps>` const Container = styled.div<ContainerProps>`
max-width: ${props => (props.fullWidth ? "100%" : "800px")}; max-width: ${(props) => (props.fullWidth ? "100%" : "800px")};
margin: auto; margin: auto;
padding: 2em; padding: 2em;
`; `;
interface IPageProps { interface PageProps {
fullWidth?: boolean; fullWidth?: boolean;
} }
const Page: React.FC<IPageProps> = ({ children, fullWidth }) => ( const Page: React.FC<PageProps> = ({ children, fullWidth }) => (
<Backdrop> <Backdrop>
<Container fullWidth={fullWidth}>{children}</Container> <Container fullWidth={fullWidth}>{children}</Container>
</Backdrop> </Backdrop>

View File

@ -10,9 +10,9 @@ import {
QUALITATIVE_COLOR_SCHEME, QUALITATIVE_COLOR_SCHEME,
QUANTITATIVE_COLOR_SCHEME, QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR, SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR SELECTED_NODE_COLOR,
} from "../../constants"; } from "../../constants";
import { IColorScheme } from "../../types"; import { ColorScheme } from "../../types";
import { getBuckets, getTypeDisplayString } from "../../util"; import { getBuckets, getTypeDisplayString } from "../../util";
const CytoscapeContainer = styled.div` const CytoscapeContainer = styled.div`
@ -21,8 +21,8 @@ const CytoscapeContainer = styled.div`
flex: 1; flex: 1;
`; `;
interface ICytoscapeProps { interface CytoscapeProps {
colorScheme?: IColorScheme; colorScheme?: ColorScheme;
currentNodeId: string | null; currentNodeId: string | null;
elements: cytoscape.ElementsDefinition; elements: cytoscape.ElementsDefinition;
hoveringOver?: string; hoveringOver?: string;
@ -32,10 +32,11 @@ interface ICytoscapeProps {
navigateToInstancePath?: (domain: string) => void; navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void; navigateToRoot?: () => void;
} }
class Cytoscape extends React.PureComponent<ICytoscapeProps> { class Cytoscape extends React.PureComponent<CytoscapeProps> {
private cy?: cytoscape.Core; private cy?: cytoscape.Core;
public componentDidMount() { public componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
const container = ReactDOM.findDOMNode(this); const container = ReactDOM.findDOMNode(this);
this.cy = cytoscape({ this.cy = cytoscape({
autoungrabify: true, autoungrabify: true,
@ -44,16 +45,16 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
hideEdgesOnViewport: true, hideEdgesOnViewport: true,
hideLabelsOnViewport: true, hideLabelsOnViewport: true,
layout: { layout: {
name: "preset" name: "preset",
}, },
maxZoom: 2, maxZoom: 2,
minZoom: 0.01, minZoom: 0.01,
pixelRatio: 1.0, pixelRatio: 1.0,
selectionType: "single" selectionType: "single",
}); });
// Setup node tooltip on hover // Setup node tooltip on hover
this.cy.nodes().forEach(n => { this.cy.nodes().forEach((n) => {
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`; const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
const ref = (n as any).popperRef(); const ref = (n as any).popperRef();
const t = tippy(ref, { const t = tippy(ref, {
@ -61,12 +62,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
animation: "fade", animation: "fade",
content: tooltipContent, content: tooltipContent,
duration: 100, duration: 100,
trigger: "manual" trigger: "manual",
}); });
n.on("mouseover", e => { n.on("mouseover", () => {
(t as Instance).show(); (t as Instance).show();
}); });
n.on("mouseout", e => { n.on("mouseout", () => {
(t as Instance).hide(); (t as Instance).hide();
}); });
}); });
@ -78,25 +79,25 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
.style({ .style({
"curve-style": "haystack", // fast edges "curve-style": "haystack", // fast edges
"line-color": DEFAULT_NODE_COLOR, "line-color": DEFAULT_NODE_COLOR,
width: "mapData(weight, 0, 0.5, 1, 20)" width: "mapData(weight, 0, 0.5, 1, 20)",
}) })
.selector("node[label]") .selector("node[label]")
.style({ .style({
color: DEFAULT_NODE_COLOR, color: DEFAULT_NODE_COLOR,
"font-size": "mapData(size, 1, 6, 10, 100)", "font-size": "mapData(size, 1, 6, 10, 100)",
"min-zoomed-font-size": 16 "min-zoomed-font-size": 16,
}) })
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges .selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
.style({ .style({
display: "none" display: "none",
}) })
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them .selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
.style({ .style({
width: 2 width: 2,
}); });
this.resetNodeColorScheme(style); // this function also called `update()` this.resetNodeColorScheme(style); // this function also called `update()`
this.cy.nodes().on("select", e => { this.cy.nodes().on("select", (e) => {
const instanceId = e.target.data("id"); const instanceId = e.target.data("id");
if (instanceId && instanceId !== this.props.currentNodeId) { if (instanceId && instanceId !== this.props.currentNodeId) {
if (this.props.navigateToInstancePath) { if (this.props.navigateToInstancePath) {
@ -110,21 +111,19 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
this.cy!.nodes().removeClass("hidden"); this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge"); this.cy!.edges().removeClass("thickEdge");
// Then hide everything except neighborhood // Then hide everything except neighborhood
this.cy!.nodes() this.cy!.nodes().diff(neighborhood).left.addClass("hidden");
.diff(neighborhood)
.left.addClass("hidden");
neighborhood.connectedEdges().addClass("thickEdge"); neighborhood.connectedEdges().addClass("thickEdge");
}); });
}); });
this.cy.nodes().on("unselect", e => { this.cy.nodes().on("unselect", () => {
this.cy!.batch(() => { this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden"); this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge"); this.cy!.edges().removeClass("thickEdge");
}); });
}); });
this.cy.on("click", e => { this.cy.on("click", (e) => {
// Clicking on the background should also deselect // Clicking on the background should also deselect
const target = e.target; const { target } = e;
if (!target || target === this.cy || target.isEdge()) { if (!target || target === this.cy || target.isEdge()) {
if (this.props.navigateToRoot) { if (this.props.navigateToRoot) {
// Go to the URL "/" // Go to the URL "/"
@ -136,7 +135,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
this.setNodeSelection(); this.setNodeSelection();
} }
public componentDidUpdate(prevProps: ICytoscapeProps) { public componentDidUpdate(prevProps: CytoscapeProps) {
this.setNodeSelection(prevProps.currentNodeId); this.setNodeSelection(prevProps.currentNodeId);
if (prevProps.colorScheme !== this.props.colorScheme) { if (prevProps.colorScheme !== this.props.colorScheme) {
this.updateColorScheme(); this.updateColorScheme();
@ -174,12 +173,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
if (currentNodeId) { if (currentNodeId) {
this.cy.zoom({ this.cy.zoom({
level: 0.2, level: 0.2,
position: this.cy.$id(currentNodeId).position() position: this.cy.$id(currentNodeId).position(),
}); });
} else { } else {
this.cy.zoom({ this.cy.zoom({
level: 0.2, level: 0.2,
position: { x: 0, y: 0 } position: { x: 0, y: 0 },
}); });
} }
} }
@ -221,7 +220,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
// quite good as it is, so... // quite good as it is, so...
height: "mapData(size, 1, 6, 20, 200)", height: "mapData(size, 1, 6, 20, 200)",
label: "data(id)", label: "data(id)",
width: "mapData(size, 1, 6, 20, 200)" width: "mapData(size, 1, 6, 20, 200)",
}); });
this.setNodeSearchColorScheme(style); this.setNodeSearchColorScheme(style);
@ -240,16 +239,16 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
"background-color": SEARCH_RESULT_COLOR, "background-color": SEARCH_RESULT_COLOR,
"border-color": SEARCH_RESULT_COLOR, "border-color": SEARCH_RESULT_COLOR,
"border-opacity": 0.7, "border-opacity": 0.7,
"border-width": 250 "border-width": 250,
}) })
.selector("node.hovered") .selector("node.hovered")
.style({ .style({
"border-color": HOVERED_NODE_COLOR, "border-color": HOVERED_NODE_COLOR,
"border-width": 1000 "border-width": 1000,
}) })
.selector("node:selected") .selector("node:selected")
.style({ .style({
"background-color": SELECTED_NODE_COLOR "background-color": SELECTED_NODE_COLOR,
}) })
.update(); .update();
}; };
@ -263,10 +262,11 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
if (!colorScheme) { if (!colorScheme) {
this.resetNodeColorScheme(); this.resetNodeColorScheme();
return; return;
} else if (colorScheme.type === "qualitative") { }
if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => { colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({ style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx] "background-color": QUALITATIVE_COLOR_SCHEME[idx],
}); });
}); });
} else if (colorScheme.type === "quantitative") { } else if (colorScheme.type === "quantitative") {
@ -284,7 +284,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1]; const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`; const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
style = style.selector(selector).style({ style = style.selector(selector).style({
"background-color": color "background-color": color,
}); });
}); });
} }
@ -304,10 +304,10 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
} }
const { hoveringOver } = this.props; const { hoveringOver } = this.props;
if (!!prevHoveredId) { if (prevHoveredId) {
this.cy.$id(prevHoveredId).removeClass("hovered"); this.cy.$id(prevHoveredId).removeClass("hovered");
} }
if (!!hoveringOver) { if (hoveringOver) {
this.cy.$id(hoveringOver).addClass("hovered"); this.cy.$id(hoveringOver).addClass("hovered");
} }
}; };
@ -322,7 +322,7 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
this.cy!.nodes().removeClass("searchResult"); this.cy!.nodes().removeClass("searchResult");
if (!!searchResultIds && searchResultIds.length > 0) { if (!!searchResultIds && searchResultIds.length > 0) {
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", "); const currentResultSelector = searchResultIds.map((id) => `node[id = "${id}"]`).join(", ");
this.cy!.$(currentResultSelector).addClass("searchResult"); this.cy!.$(currentResultSelector).addClass("searchResult");
} }
}); });
@ -344,11 +344,11 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
/* Helper function to remove edges if source or target node is missing */ /* Helper function to remove edges if source or target node is missing */
private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => { private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => {
const domains = new Set(elements.nodes.map(n => n.data.id)); const domains = new Set(elements.nodes.map((n) => n.data.id));
const edges = elements.edges.filter(e => domains.has(e.data.source) && domains.has(e.data.target)); const edges = elements.edges.filter((e) => domains.has(e.data.source) && domains.has(e.data.target));
return { return {
edges, edges,
nodes: elements.nodes nodes: elements.nodes,
}; };
}; };
} }

View File

@ -2,11 +2,11 @@ import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import * as React from "react"; import * as React from "react";
interface IErrorStateProps { interface ErrorStateProps {
description?: string; description?: string;
} }
const ErrorState: React.FC<IErrorStateProps> = ({ description }) => ( const ErrorState: React.FC<ErrorStateProps> = ({ description }) => (
<NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} description={description} /> <NonIdealState icon={IconNames.ERROR} title="Something went wrong." description={description} />
); );
export default ErrorState; export default ErrorState;

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { IColorScheme } from "../../types"; import { ColorScheme } from "../../types";
import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms"; import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div` const GraphToolsContainer = styled.div`
@ -11,35 +11,33 @@ const GraphToolsContainer = styled.div`
flex-direction: column; flex-direction: column;
`; `;
interface IGraphToolsProps { interface GraphToolsProps {
currentColorScheme?: IColorScheme; currentColorScheme?: ColorScheme;
colorSchemes: IColorScheme[]; colorSchemes: ColorScheme[];
isShowingEdges: boolean; isShowingEdges: boolean;
ranges?: { [key: string]: [number, number] }; ranges?: { [key: string]: [number, number] };
onColorSchemeSelect: (colorScheme?: IColorScheme) => void; onColorSchemeSelect: (colorScheme?: ColorScheme) => void;
onResetButtonClick: () => void; onResetButtonClick: () => void;
toggleEdges: () => void; toggleEdges: () => void;
} }
const GraphTools: React.FC<IGraphToolsProps> = ({ const GraphTools: React.FC<GraphToolsProps> = ({
currentColorScheme, currentColorScheme,
colorSchemes, colorSchemes,
isShowingEdges, isShowingEdges,
ranges, ranges,
onColorSchemeSelect, onColorSchemeSelect,
onResetButtonClick, onResetButtonClick,
toggleEdges toggleEdges,
}) => { }) => (
return ( <GraphToolsContainer>
<GraphToolsContainer> <GraphResetButton onClick={onResetButtonClick} />
<GraphResetButton onClick={onResetButtonClick} /> <GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} /> <GraphKey
<GraphKey current={currentColorScheme}
current={currentColorScheme} colorSchemes={colorSchemes}
colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect}
onItemSelect={onColorSchemeSelect} ranges={ranges}
ranges={ranges} />
/> </GraphToolsContainer>
</GraphToolsContainer> );
);
};
export default GraphTools; export default GraphTools;

View File

@ -4,7 +4,7 @@ import * as numeral from "numeral";
import React from "react"; import React from "react";
import sanitize from "sanitize-html"; import sanitize from "sanitize-html";
import styled from "styled-components"; import styled from "styled-components";
import { ISearchResultInstance } from "../../redux/types"; import { SearchResultInstance } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
const StyledCard = styled(Card)` const StyledCard = styled(Card)`
@ -32,18 +32,18 @@ const StyledUserCount = styled.div`
const StyledDescription = styled.div` const StyledDescription = styled.div`
margin-top: 10px; margin-top: 10px;
`; `;
interface ISearchResultProps { interface SearchResultProps {
result: ISearchResultInstance; result: SearchResultInstance;
onClick: () => void; onClick: () => void;
onMouseEnter: () => void; onMouseEnter: () => void;
onMouseLeave: () => void; onMouseLeave: () => void;
} }
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => { const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
let shortenedDescription; let shortenedDescription;
if (result.description) { if (result.description) {
shortenedDescription = result.description && sanitize(result.description); shortenedDescription = result.description && sanitize(result.description);
if (shortenedDescription.length > 100) { if (shortenedDescription.length > 100) {
shortenedDescription = shortenedDescription.substring(0, 100) + "..."; shortenedDescription = `${shortenedDescription.substring(0, 100)}...`;
} }
} }
@ -59,7 +59,7 @@ const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEn
return ( return (
<StyledCard <StyledCard
elevation={Elevation.ONE} elevation={Elevation.ONE}
interactive={true} interactive
key={result.name} key={result.name}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}

View File

@ -1,12 +1,12 @@
import { Classes, H3 } from "@blueprintjs/core"; import { Classes, H3 } from "@blueprintjs/core";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IFederationRestrictions } from "../../redux/types"; import { FederationRestrictions } from "../../redux/types";
const maybeGetList = (domains?: string[]) => const maybeGetList = (domains?: string[]) =>
domains && ( domains && (
<ul> <ul>
{domains.sort().map(domain => ( {domains.sort().map((domain) => (
<li key={domain}> <li key={domain}>
<Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button"> <Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{domain} {domain}
@ -16,10 +16,10 @@ const maybeGetList = (domains?: string[]) =>
</ul> </ul>
); );
interface IFederationTabProps { interface FederationTabProps {
restrictions?: IFederationRestrictions; restrictions?: FederationRestrictions;
} }
const FederationTab: React.FC<IFederationTabProps> = ({ restrictions }) => { const FederationTab: React.FC<FederationTabProps> = ({ restrictions }) => {
if (!restrictions) { if (!restrictions) {
return null; return null;
} }

View File

@ -6,33 +6,33 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { fetchGraph } from "../../redux/actions"; import { fetchGraph } from "../../redux/actions";
import { IAppState, IGraphResponse } from "../../redux/types"; import { AppState, GraphResponse } from "../../redux/types";
import { colorSchemes, IColorScheme } from "../../types"; import { colorSchemes, ColorScheme } from "../../types";
import { domainMatchSelector } from "../../util"; import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules/"; import { Cytoscape, ErrorState, GraphTools } from "../molecules";
const GraphDiv = styled.div` const GraphDiv = styled.div`
flex: 2; flex: 2;
`; `;
interface IGraphProps { interface GraphProps {
currentInstanceName: string | null; currentInstanceName: string | null;
fetchGraph: () => void; fetchGraph: () => void;
graphResponse?: IGraphResponse; graphResponse?: GraphResponse;
graphLoadError: boolean; graphLoadError: boolean;
hoveringOverResult?: string; hoveringOverResult?: string;
isLoadingGraph: boolean; isLoadingGraph: boolean;
searchResultDomains: string[]; searchResultDomains: string[];
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface IGraphState { interface GraphState {
colorScheme?: IColorScheme; colorScheme?: ColorScheme;
isShowingEdges: boolean; isShowingEdges: boolean;
} }
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> { class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
private cytoscapeComponent: React.RefObject<Cytoscape>; private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: IGraphProps) { public constructor(props: GraphProps) {
super(props); super(props);
this.cytoscapeComponent = React.createRef(); this.cytoscapeComponent = React.createRef();
this.state = { colorScheme: undefined, isShowingEdges: true }; this.state = { colorScheme: undefined, isShowingEdges: true };
@ -76,7 +76,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
); );
} }
return <GraphDiv>{content}</GraphDiv>; return <GraphDiv aria-hidden>{content}</GraphDiv>;
} }
private loadGraph = () => { private loadGraph = () => {
@ -95,7 +95,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
this.setState({ isShowingEdges: !this.state.isShowingEdges }); this.setState({ isShowingEdges: !this.state.isShowingEdges });
}; };
private setColorScheme = (colorScheme?: IColorScheme) => { private setColorScheme = (colorScheme?: ColorScheme) => {
this.setState({ colorScheme }); this.setState({ colorScheme });
}; };
@ -107,7 +107,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
this.props.navigate("/"); this.props.navigate("/");
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
@ -115,15 +115,12 @@ const mapStateToProps = (state: IAppState) => {
graphResponse: state.data.graphResponse, graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult, hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph, isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map(r => r.name) searchResultDomains: state.search.results.map((r) => r.name),
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any), fetchGraph: () => dispatch(fetchGraph() as any),
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path)),
}); });
const Graph = connect( const Graph = connect(mapStateToProps, mapDispatchToProps)(GraphImpl);
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph; export default Graph;

View File

@ -8,7 +8,7 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { loadInstanceList } from "../../redux/actions"; import { loadInstanceList } from "../../redux/actions";
import { IAppState, IInstanceListResponse, IInstanceSort, SortField } from "../../redux/types"; import { AppState, InstanceListResponse, InstanceSort, SortField, InstanceDetails } from "../../redux/types";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
@ -41,15 +41,15 @@ const InsularityColumn = styled.th`
width: 15%; width: 15%;
`; `;
interface IInstanceTableProps { interface InstanceTableProps {
loadError: boolean; loadError: boolean;
instancesResponse?: IInstanceListResponse; instancesResponse?: InstanceListResponse;
instanceListSort: IInstanceSort; instanceListSort: InstanceSort;
isLoading: boolean; isLoading: boolean;
loadInstanceList: (page?: number, sort?: IInstanceSort) => void; loadInstanceList: (page?: number, sort?: InstanceSort) => void;
navigate: (path: string) => void; navigate: (path: string) => void;
} }
class InstanceTable extends React.PureComponent<IInstanceTableProps> { class InstanceTable extends React.PureComponent<InstanceTableProps> {
public componentDidMount() { public componentDidMount() {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (!isLoading && !instancesResponse && !loadError) { if (!isLoading && !instancesResponse && !loadError) {
@ -61,22 +61,23 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const { isLoading, instancesResponse, loadError } = this.props; const { isLoading, instancesResponse, loadError } = this.props;
if (loadError) { if (loadError) {
return <ErrorState />; return <ErrorState />;
} else if (isLoading || !instancesResponse) { }
if (isLoading || !instancesResponse) {
return <NonIdealState icon={<Spinner />} />; return <NonIdealState icon={<Spinner />} />;
} }
const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse!; const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse;
const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage); const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage);
return ( return (
<> <>
<StyledTable striped={true} bordered={true} interactive={true}> <StyledTable striped bordered interactive>
<thead> <thead>
<tr> <tr>
<InstanceColumn> <InstanceColumn>
Instance Instance
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("domain")} icon={this.getSortIcon("domain")}
onClick={this.sortByFactory("domain")} onClick={this.sortByFactory("domain")}
intent={this.getSortIntent("domain")} intent={this.getSortIntent("domain")}
@ -87,7 +88,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<UserCountColumn> <UserCountColumn>
Users Users
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("userCount")} icon={this.getSortIcon("userCount")}
onClick={this.sortByFactory("userCount")} onClick={this.sortByFactory("userCount")}
intent={this.getSortIntent("userCount")} intent={this.getSortIntent("userCount")}
@ -96,7 +97,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<StatusCountColumn> <StatusCountColumn>
Statuses Statuses
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("statusCount")} icon={this.getSortIcon("statusCount")}
onClick={this.sortByFactory("statusCount")} onClick={this.sortByFactory("statusCount")}
intent={this.getSortIntent("statusCount")} intent={this.getSortIntent("statusCount")}
@ -105,7 +106,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
<InsularityColumn> <InsularityColumn>
Insularity Insularity
<Button <Button
minimal={true} minimal
icon={this.getSortIcon("insularity")} icon={this.getSortIcon("insularity")}
onClick={this.sortByFactory("insularity")} onClick={this.sortByFactory("insularity")}
intent={this.getSortIntent("insularity")} intent={this.getSortIntent("insularity")}
@ -114,7 +115,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{instances.map(i => ( {instances.map((i: InstanceDetails) => (
<tr key={i.name} onClick={this.goToInstanceFactory(i.name)}> <tr key={i.name} onClick={this.goToInstanceFactory(i.name)}>
<td>{i.name}</td> <td>{i.name}</td>
<td>{i.type && <InstanceType type={i.type} />}</td> <td>{i.type && <InstanceType type={i.type} />}</td>
@ -134,7 +135,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
</p> </p>
<ButtonGroup> <ButtonGroup>
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => { {zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage]) => {
if (page === undefined) { if (page === undefined) {
return null; return null;
} }
@ -142,7 +143,7 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages; const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages;
return ( return (
<> <React.Fragment key={page}>
<Button <Button
key={page} key={page}
onClick={this.loadPageFactory(page)} onClick={this.loadPageFactory(page)}
@ -152,11 +153,11 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
{page} {page}
</Button> </Button>
{isEndOfSection && ( {isEndOfSection && (
<Button disabled={true} key={"..."}> <Button disabled key="...">
{"..."} ...
</Button> </Button>
)} )}
</> </React.Fragment>
); );
})} })}
</ButtonGroup> </ButtonGroup>
@ -187,20 +188,19 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const { instanceListSort } = this.props; const { instanceListSort } = this.props;
if (instanceListSort.field !== field) { if (instanceListSort.field !== field) {
return IconNames.SORT; return IconNames.SORT;
} else if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC;
} else {
return IconNames.SORT_DESC;
} }
if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC;
}
return IconNames.SORT_DESC;
}; };
private getSortIntent = (field: SortField) => { private getSortIntent = (field: SortField) => {
const { instanceListSort } = this.props; const { instanceListSort } = this.props;
if (instanceListSort.field === field) { if (instanceListSort.field === field) {
return Intent.PRIMARY; return Intent.PRIMARY;
} else {
return Intent.NONE;
} }
return Intent.NONE;
}; };
private getPagesToDisplay = (totalPages: number, currentPage: number) => { private getPagesToDisplay = (totalPages: number, currentPage: number) => {
@ -214,24 +214,19 @@ class InstanceTable extends React.PureComponent<IInstanceTableProps> {
const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages); const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages);
return sortedUniq(sortBy(pagesToDisplay, n => n)); return sortedUniq(sortBy(pagesToDisplay, (n) => n));
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => ({
return { instanceListSort: state.data.instanceListSort,
instanceListSort: state.data.instanceListSort, instancesResponse: state.data.instancesResponse,
instancesResponse: state.data.instancesResponse, isLoading: state.data.isLoadingInstanceList,
isLoading: state.data.isLoadingInstanceList, loadError: state.data.instanceListLoadError,
loadError: state.data.instanceListLoadError });
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstanceList: (page?: number, sort?: IInstanceSort) => dispatch(loadInstanceList(page, sort) as any), loadInstanceList: (page?: number, sort?: InstanceSort) => dispatch(loadInstanceList(page, sort) as any),
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path)),
}); });
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(InstanceTable);
mapStateToProps,
mapDispatchToProps
)(InstanceTable);

View File

@ -1,21 +1,19 @@
import * as React from "react"; import * as React from "react";
import { Alignment, Navbar } from "@blueprintjs/core"; import { Alignment, Navbar, Classes } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom"; import { match, NavLink } from "react-router-dom";
import { IInstanceDomainPath } from "../../constants"; import { InstanceDomainPath } from "../../constants";
interface INavState { interface NavState {
aboutIsOpen: boolean; aboutIsOpen: boolean;
} }
const graphIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => { const graphIsActive = (currMatch: match<InstanceDomainPath>, location: Location) =>
return location.pathname === "/" || location.pathname.startsWith("/instance/"); location.pathname === "/" || location.pathname.startsWith("/instance/");
};
class Nav extends React.Component<{}, INavState> { class Nav extends React.Component<{}, NavState> {
constructor(props: any) { constructor(props: any) {
super(props); super(props);
this.state = { aboutIsOpen: false }; this.state = { aboutIsOpen: false };
@ -23,44 +21,46 @@ class Nav extends React.Component<{}, INavState> {
public render() { public render() {
return ( return (
<Navbar fixedToTop={true}> <nav role="navigation">
<Navbar.Group align={Alignment.LEFT}> <Navbar fixedToTop={true}>
<Navbar.Heading>fediverse.space</Navbar.Heading> <Navbar.Group align={Alignment.LEFT}>
<Navbar.Divider /> <Navbar.Heading>fediverse.space</Navbar.Heading>
<NavLink <Navbar.Divider />
to="/" <NavLink
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`} to="/"
activeClassName={Classes.INTENT_PRIMARY} className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
isActive={graphIsActive as any} isActive={graphIsActive as any}
> activeClassName="current-navbar-item"
Home >
</NavLink> Home
<NavLink </NavLink>
to="/instances" <NavLink
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`} to="/instances"
activeClassName={Classes.INTENT_PRIMARY} className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`}
> activeClassName="current-navbar-item"
Instances >
</NavLink> Instances
<NavLink </NavLink>
to="/about" <NavLink
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`} to="/about"
activeClassName={Classes.INTENT_PRIMARY} className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
exact={true} activeClassName="current-navbar-item"
> exact={true}
About >
</NavLink> About
</Navbar.Group> </NavLink>
<Navbar.Group align={Alignment.RIGHT}> </Navbar.Group>
<NavLink <Navbar.Group align={Alignment.RIGHT}>
to="/admin" <NavLink
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`} to="/admin"
activeClassName={Classes.INTENT_PRIMARY} className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`}
> activeClassName="current-navbar-item"
Administration >
</NavLink> Administration
</Navbar.Group> </NavLink>
</Navbar> </Navbar.Group>
</Navbar>
</nav>
); );
} }
} }

View File

@ -3,7 +3,7 @@ import { IconNames } from "@blueprintjs/icons";
import React, { MouseEvent } from "react"; import React, { MouseEvent } from "react";
import styled from "styled-components"; import styled from "styled-components";
import { INSTANCE_TYPES } from "../../constants"; import { INSTANCE_TYPES } from "../../constants";
import { getSearchFilterDisplayValue, ISearchFilter } from "../../searchFilters"; import { getSearchFilterDisplayValue, SearchFilter } from "../../searchFilters";
import { getTypeDisplayString } from "../../util"; import { getTypeDisplayString } from "../../util";
const SearchFilterContainer = styled.div` const SearchFilterContainer = styled.div`
@ -19,30 +19,30 @@ const StyledTag = styled(Tag)`
margin-left: 5px; margin-left: 5px;
`; `;
interface ISearchFiltersProps { interface SearchFiltersProps {
selectedFilters: ISearchFilter[]; selectedFilters: SearchFilter[];
selectFilter: (filter: ISearchFilter) => void; selectFilter: (filter: SearchFilter) => void;
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void; deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
} }
const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => { const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type"); const hasInstanceTypeFilter = selectedFilters.some((sf) => sf.field === "type");
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => { const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
const field = "type"; const field = "type";
const relation = "eq"; const relation = "eq";
const value = e.currentTarget.innerText.toLowerCase().replace(" ", ""); const value = e.currentTarget.innerText.toLowerCase().replace(" ", "");
const filter: ISearchFilter = { const filter: SearchFilter = {
displayValue: getSearchFilterDisplayValue(field, relation, value), displayValue: getSearchFilterDisplayValue(field, relation, value),
field, field,
relation, relation,
value value,
}; };
selectFilter(filter); selectFilter(filter);
}; };
const renderMenu = () => ( const renderMenu = () => (
<Menu> <Menu>
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}> <MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
{INSTANCE_TYPES.map(t => ( {INSTANCE_TYPES.map((t) => (
<MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} /> <MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} />
))} ))}
</MenuItem> </MenuItem>
@ -51,15 +51,15 @@ const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectF
return ( return (
<SearchFilterContainer> <SearchFilterContainer>
<TagContainer> <TagContainer>
{selectedFilters.map(filter => ( {selectedFilters.map((filter) => (
<StyledTag key={filter.displayValue} minimal={true} onRemove={deselectFilter}> <StyledTag key={filter.displayValue} minimal onRemove={deselectFilter}>
{filter.displayValue} {filter.displayValue}
</StyledTag> </StyledTag>
))} ))}
</TagContainer> </TagContainer>
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}> <Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
<Button minimal={true} icon={IconNames.FILTER}> <Button minimal icon={IconNames.FILTER}>
{"Add filter"} Add filter
</Button> </Button>
</Popover> </Popover>
</SearchFilterContainer> </SearchFilterContainer>

View File

@ -17,11 +17,9 @@ const StyledCard = styled(Card)`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
`; `;
const SidebarContainer: React.FC = ({ children }) => { const SidebarContainer: React.FC = ({ children }) => (
return ( <RightDiv>
<RightDiv> <StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard> </RightDiv>
</RightDiv> );
);
};
export default SidebarContainer; export default SidebarContainer;

View File

@ -1,16 +1,14 @@
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core"; import { Classes, Code, H1, H2, H3 } from "@blueprintjs/core";
import * as React from "react"; import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
// import appsignalLogo from "../../assets/appsignal.svg"; import * as nlnetLogo from "../../assets/nlnet.png";
import gitlabLogo from "../../assets/gitlab.png"; import { Page } from "../atoms";
import nlnetLogo from "../../assets/nlnet.png";
import { Page } from "../atoms/";
const SponsorContainer = styled.div` const SponsorContainer = styled.div`
margin-bottom: 20px; margin-bottom: 20px;
`; `;
const Sponsor = styled.div` const Sponsor = styled.div`
margin: 10px; margin: 10px 40px 10px 0;
display: inline-block; display: inline-block;
`; `;
@ -27,36 +25,41 @@ const AboutScreen: React.FC = () => (
<p> <p>
You can follow the project on{" "} You can follow the project on{" "}
<a href="https://mastodon.social/@fediversespace" target="_blank" rel="noopener noreferrer"> <a href="https://social.inex.rocks/@indexCommunity" target="_blank" rel="noopener noreferrer">
Mastodon Mastodon
</a> </a>
. .
</p> </p>
<p>
This is a fork of the original fediverse.space by{" "}
<a href="https://www.btao.org" target="_blank" rel="noopener noreferrer">
Tao Bojlén
</a>
.
</p>
<br /> <br />
<H2>FAQ</H2> <H2>FAQ</H2>
<H4>Why can't I see details about my instance?</H4> <H3>Why can&apos;t I see details about my instance?</H3>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo. fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo.
Instances with 10 or fewer users won't be scraped -- it's a tool for understanding communities, not individuals. Instances with 10 or fewer users won&apos;t be crawled -- it&apos;s a tool for understanding communities, not
individuals.
</p> </p>
<H4> <H3>
When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added? When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added?
</H4> </H3>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
Check out{" "} We are in the early forking-out phase, and don&apos;t provide any support yet. Check back later.
<a href="https://gitlab.com/taobojlen/fediverse.space/issues/24" target="_blank" rel="noopener noreferrer">
this GitLab issue
</a>
.
</p> </p>
<H4>How do I add my personal instance?</H4> <H3>How do I add my personal instance?</H3>
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p> <p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
<H4>How do you calculate the strength of relationships between instances?</H4> <H3>How do you calculate the strength of relationships between instances?</H3>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It
calculates at the ratio of calculates at the ratio of
@ -64,6 +67,23 @@ const AboutScreen: React.FC = () => (
to reflect that smaller instances can play a large role in a community. to reflect that smaller instances can play a large role in a community.
</p> </p>
<H3>Who maintains this instance?</H3>
<p className={Classes.RUNNING_TEXT}>
index.community (fork domain) is an{" "}
<a href="https://innereq.org" target="_blank" rel="noopener noreferrer">
InnerEq.org
</a>{" "}
project maintained by Inex Code. You can help cover the cost of the server by becoming a{" "}
<a href="https://www.patreon.com/inexcode" target="_blank" rel="noopener noreferrer">
patron
</a>{" "}
or donating some{" "}
<a href="https://inex.rocks/#donate" target="_blank" rel="noopener noreferrer">
crypto
</a>
.
</p>
<br /> <br />
<H2>Special thanks</H2> <H2>Special thanks</H2>
@ -74,16 +94,6 @@ const AboutScreen: React.FC = () => (
</a> </a>
</Sponsor> </Sponsor>
<br /> <br />
{/* <Sponsor>
<a href="https://appsignal.com" target="_blank" rel="noopener noreferrer">
<img src={appsignalLogo} alt="Appsignal logo" height={40} />
</a>
</Sponsor> */}
<Sponsor>
<a href="https://gitlab.com" target="_blank" rel="noopener noreferrer">
<img src={gitlabLogo} alt="GitLab logo" height={40} />
</a>
</Sponsor>
</SponsorContainer> </SponsorContainer>
<p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p> <p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p>
@ -114,7 +124,7 @@ const AboutScreen: React.FC = () => (
</ul> </ul>
<p> <p>
The source code for fediverse.space is available on{" "} The source code for fediverse.space is available on{" "}
<a href="https://gitlab.com/taobojlen/fediverse.space" target="_blank" rel="noopener noreferrer"> <a href="https://gitlab.com/fediverse.space/fediverse.space" target="_blank" rel="noopener noreferrer">
GitLab GitLab
</a> </a>
; issues and pull requests are welcome! ; issues and pull requests are welcome!

View File

@ -17,7 +17,7 @@ const ButtonContainer = styled.div`
justify-content: space-between; justify-content: space-between;
`; `;
interface IAdminSettings { interface AdminSettings {
domain: string; domain: string;
optIn: boolean; optIn: boolean;
optOut: boolean; optOut: boolean;
@ -25,26 +25,26 @@ interface IAdminSettings {
statusCount: number; statusCount: number;
} }
interface IAdminScreenProps { interface AdminScreenProps {
navigate: (path: string) => void; navigate: (path: string) => void;
} }
interface IAdminScreenState { interface AdminScreenState {
settings?: IAdminSettings; settings?: AdminSettings;
isUpdating: boolean; isUpdating: boolean;
} }
class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenState> { class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState> {
private authToken = getAuthToken(); private authToken = getAuthToken();
public constructor(props: IAdminScreenProps) { public constructor(props: AdminScreenProps) {
super(props); super(props);
this.state = { isUpdating: false }; this.state = { isUpdating: false };
} }
public componentDidMount() { public componentDidMount() {
// Load instance settings from server // Load instance settings from server
if (!!this.authToken) { if (this.authToken) {
getFromApi(`admin`, this.authToken!) getFromApi(`admin`, this.authToken)
.then(response => { .then((response) => {
this.setState({ settings: response }); this.setState({ settings: response });
}) })
.catch(() => { .catch(() => {
@ -52,7 +52,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
icon: IconNames.ERROR, icon: IconNames.ERROR,
intent: Intent.DANGER, intent: Intent.DANGER,
message: "Failed to load settings.", message: "Failed to load settings.",
timeout: 0 timeout: 0,
}); });
unsetAuthToken(); unsetAuthToken();
}); });
@ -78,7 +78,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
<Switch <Switch
id="opt-in-switch" id="opt-in-switch"
checked={!!settings.optIn} checked={!!settings.optIn}
large={true} large
label="Opt in" label="Opt in"
disabled={!!isUpdating} disabled={!!isUpdating}
onChange={this.updateOptIn} onChange={this.updateOptIn}
@ -89,7 +89,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
<Switch <Switch
id="opt-out-switch" id="opt-out-switch"
checked={!!settings.optOut} checked={!!settings.optOut}
large={true} large
label="Opt out" label="Opt out"
disabled={!!isUpdating} disabled={!!isUpdating}
onChange={this.updateOptOut} onChange={this.updateOptOut}
@ -116,9 +116,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
} }
private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => { private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as IAdminSettings; const settings = this.state.settings as AdminSettings;
const optIn = e.currentTarget.checked; const optIn = e.currentTarget.checked;
let optOut = settings.optOut; let { optOut } = settings;
if (optIn) { if (optIn) {
optOut = false; optOut = false;
} }
@ -126,9 +126,9 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
}; };
private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => { private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as IAdminSettings; const settings = this.state.settings as AdminSettings;
const optOut = e.currentTarget.checked; const optOut = e.currentTarget.checked;
let optIn = settings.optIn; let { optIn } = settings;
if (optOut) { if (optOut) {
optIn = false; optIn = false;
} }
@ -140,15 +140,15 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
this.setState({ isUpdating: true }); this.setState({ isUpdating: true });
const body = { const body = {
optIn: this.state.settings!.optIn, optIn: this.state.settings!.optIn,
optOut: this.state.settings!.optOut optOut: this.state.settings!.optOut,
}; };
postToApi(`admin`, body, this.authToken!) postToApi(`admin`, body, this.authToken!)
.then(response => { .then((response) => {
this.setState({ settings: response, isUpdating: false }); this.setState({ settings: response, isUpdating: false });
AppToaster.show({ AppToaster.show({
icon: IconNames.TICK, icon: IconNames.TICK,
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
message: "Successfully updated settings." message: "Successfully updated settings.",
}); });
}) })
.catch(() => { .catch(() => {
@ -161,16 +161,13 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
unsetAuthToken(); unsetAuthToken();
AppToaster.show({ AppToaster.show({
icon: IconNames.LOG_OUT, icon: IconNames.LOG_OUT,
message: "Logged out." message: "Logged out.",
}); });
this.props.navigate("/admin/login"); this.props.navigate("/admin/login");
}; };
} }
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigate: (path: string) => dispatch(push(path)) navigate: (path: string) => dispatch(push(path)),
}); });
export default connect( export default connect(undefined, mapDispatchToProps)(AdminScreen);
undefined,
mapDispatchToProps
)(AdminScreen);

View File

@ -7,9 +7,9 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { InstanceScreen, SearchScreen } from "."; import { InstanceScreen, SearchScreen } from ".";
import { INSTANCE_DOMAIN_PATH } from "../../constants"; import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions"; import { loadInstance } from "../../redux/actions";
import { IAppState } from "../../redux/types"; import { AppState } from "../../redux/types";
import { domainMatchSelector, isSmallScreen } from "../../util"; import { domainMatchSelector, isSmallScreen } from "../../util";
import { Graph, SidebarContainer } from "../organisms/"; import { Graph, SidebarContainer } from "../organisms";
const GraphContainer = styled.div` const GraphContainer = styled.div`
display: flex; display: flex;
@ -24,13 +24,13 @@ const FullDiv = styled.div`
right: 0; right: 0;
`; `;
interface IGraphScreenProps extends RouteComponentProps { interface GraphScreenProps extends RouteComponentProps {
currentInstanceName: string | null; currentInstanceName: string | null;
pathname: string; pathname: string;
graphLoadError: boolean; graphLoadError: boolean;
loadInstance: (domain: string | null) => void; loadInstance: (domain: string | null) => void;
} }
interface IGraphScreenState { interface GraphScreenState {
hasBeenViewed: boolean; hasBeenViewed: boolean;
} }
/** /**
@ -41,8 +41,8 @@ interface IGraphScreenState {
* However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the * However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the
* graph since it slows down everything else! * graph since it slows down everything else!
*/ */
class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenState> { class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState> {
public constructor(props: IGraphScreenProps) { public constructor(props: GraphScreenProps) {
super(props); super(props);
this.state = { hasBeenViewed: false }; this.state = { hasBeenViewed: false };
} }
@ -56,7 +56,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
this.loadCurrentInstance(); this.loadCurrentInstance();
} }
public componentDidUpdate(prevProps: IGraphScreenProps) { public componentDidUpdate(prevProps: GraphScreenProps) {
this.setHasBeenViewed(); this.setHasBeenViewed();
this.loadCurrentInstance(prevProps.currentInstanceName); this.loadCurrentInstance(prevProps.currentInstanceName);
} }
@ -72,7 +72,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
} }
}; };
private renderRoutes = ({ location }: RouteComponentProps) => ( private renderRoutes = () => (
<FullDiv> <FullDiv>
<GraphContainer> <GraphContainer>
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */} {/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
@ -80,7 +80,7 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
<SidebarContainer> <SidebarContainer>
<Switch> <Switch>
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} /> <Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
<Route exact={true} path="/" component={SearchScreen} /> <Route exact path="/" component={SearchScreen} />
</Switch> </Switch>
</SidebarContainer> </SidebarContainer>
</GraphContainer> </GraphContainer>
@ -94,19 +94,16 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenSta
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
currentInstanceName: match && match.params.domain, currentInstanceName: match && match.params.domain,
graphLoadError: state.data.graphLoadError, graphLoadError: state.data.graphLoadError,
pathname: state.router.location.pathname pathname: state.router.location.pathname,
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any) loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any),
}); });
const GraphScreen = connect( const GraphScreen = connect(mapStateToProps, mapDispatchToProps)(GraphScreenImpl);
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);
export default withRouter(GraphScreen); export default withRouter(GraphScreen);

View File

@ -20,7 +20,7 @@ import {
Spinner, Spinner,
Tab, Tab,
Tabs, Tabs,
Tooltip Tooltip,
} from "@blueprintjs/core"; } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
@ -28,10 +28,10 @@ import { push } from "connected-react-router";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { IAppState, IGraph, IGraphResponse, IInstanceDetails } from "../../redux/types"; import { AppState, Graph, GraphResponse, InstanceDetails, Peer } from "../../redux/types";
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util"; import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
import { InstanceType } from "../atoms"; import { InstanceType } from "../atoms";
import { Cytoscape, ErrorState } from "../molecules/"; import { Cytoscape, ErrorState } from "../molecules";
import { FederationTab } from "../organisms"; import { FederationTab } from "../organisms";
const InstanceScreenContainer = styled.div` const InstanceScreenContainer = styled.div`
@ -82,25 +82,25 @@ const StyledGraphContainer = styled.div`
flex-direction: column; flex-direction: column;
margin-bottom: 10px; margin-bottom: 10px;
`; `;
interface IInstanceScreenProps { interface InstanceScreenProps {
graph?: IGraph; graph?: Graph;
instanceName: string | null; instanceName: string | null;
instanceLoadError: boolean; instanceLoadError: boolean;
instanceDetails: IInstanceDetails | null; instanceDetails: InstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
navigateToRoot: () => void; navigateToRoot: () => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
} }
interface IInstanceScreenState { interface InstanceScreenState {
neighbors?: string[]; neighbors?: string[];
isProcessingNeighbors: boolean; isProcessingNeighbors: boolean;
// Local (neighborhood) graph. Used only on small screens (mobile devices). // Local (neighborhood) graph. Used only on small screens (mobile devices).
isLoadingLocalGraph: boolean; isLoadingLocalGraph: boolean;
localGraph?: IGraph; localGraph?: Graph;
localGraphLoadError?: boolean; localGraphLoadError?: boolean;
} }
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> { class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, InstanceScreenState> {
public constructor(props: IInstanceScreenProps) { public constructor(props: InstanceScreenProps) {
super(props); super(props);
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false }; this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
} }
@ -116,9 +116,9 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
!this.props.instanceDetails.status !this.props.instanceDetails.status
) { ) {
content = <ErrorState />; content = <ErrorState />;
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) { } else if (this.props.instanceDetails.status.toLowerCase().includes("personal instance")) {
content = this.renderPersonalInstanceErrorState(); content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) { } else if (this.props.instanceDetails.status.toLowerCase().includes("robots.txt")) {
content = this.renderRobotsTxtState(); content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") { } else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState(); content = this.renderMissingDataState();
@ -130,7 +130,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
<HeadingContainer> <HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2> <StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}> <StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} /> <AnchorButton icon={IconNames.LINK} minimal onClick={this.openInstanceLink} />
</StyledHeadingTooltip> </StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} /> <StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer> </HeadingContainer>
@ -145,7 +145,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
this.processEdgesToFindNeighbors(); this.processEdgesToFindNeighbors();
} }
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) { public componentDidUpdate(prevProps: InstanceScreenProps, prevState: InstanceScreenState) {
const isNewInstance = prevProps.instanceName !== this.props.instanceName; const isNewInstance = prevProps.instanceName !== this.props.instanceName;
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors; const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph; const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
@ -164,10 +164,13 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
} }
this.setState({ isProcessingNeighbors: true }); this.setState({ isProcessingNeighbors: true });
const graphToUse = !!graph ? graph : localGraph; const graphToUse = graph || localGraph;
const edges = graphToUse!.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1); if (!graphToUse) {
return;
}
const edges = graphToUse.edges.filter((e) => [e.data.source, e.data.target].includes(instanceName));
const neighbors: any[] = []; const neighbors: any[] = [];
edges.forEach(e => { edges.forEach((e) => {
if (e.data.source === instanceName) { if (e.data.source === instanceName) {
neighbors.push({ neighbor: e.data.target, weight: e.data.weight }); neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
} else { } else {
@ -183,39 +186,38 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
} }
this.setState({ isLoadingLocalGraph: true }); this.setState({ isLoadingLocalGraph: true });
getFromApi(`graph/${this.props.instanceName}`) getFromApi(`graph/${this.props.instanceName}`)
.then((response: IGraphResponse) => { .then((response: GraphResponse) => {
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood // 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 // We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
// queries. // queries.
// TODO: think more about moving the backend to a graph database that would make this easier. // TODO: think more about moving the backend to a graph database that would make this easier.
const graph = response.graph; const { graph } = response;
const nodeIds = new Set(graph.nodes.map(n => n.data.id)); const nodeIds = new Set(graph.nodes.map((n) => n.data.id));
const edges = graph.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target)); const edges = graph.edges.filter((e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } }); this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } });
}) })
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true })); .catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
}; };
private renderTabs = () => { private renderTabs = () => {
const { instanceDetails } = this.props;
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0; const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const federationRestrictions = this.props.instanceDetails && this.props.instanceDetails.federationRestrictions; const federationRestrictions = instanceDetails && instanceDetails.federationRestrictions;
const hasLocalGraph = const hasLocalGraph =
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0; !!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
const insularCallout = const insularCallout =
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? ( this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance"> <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> <p>This instance doesn&apos;t have any neighbors that we know of, so it&apos;s hidden from the graph.</p>
</StyledCallout> </StyledCallout>
) : ( ) : undefined;
undefined
);
return ( return (
<> <>
{insularCallout} {insularCallout}
{this.maybeRenderLocalGraph()} {this.maybeRenderLocalGraph()}
<StyledTabs> <StyledTabs>
{this.props.instanceDetails!.description && ( {instanceDetails && instanceDetails.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} /> <Tab id="description" title="Description" panel={this.renderDescription()} />
)} )}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />} {this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
@ -231,11 +233,11 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</StyledTabs> </StyledTabs>
<StyledLinkToFdNetwork> <StyledLinkToFdNetwork>
<AnchorButton <AnchorButton
href={`https://fediverse.network/${this.props.instanceName}`} href={`https://fedidb.org/network/instance?domain=${this.props.instanceName}`}
minimal={true} minimal
rightIcon={IconNames.SHARE} rightIcon={IconNames.SHARE}
target="_blank" target="_blank"
text="See more statistics at fediverse.network" text="See more statistics at fedidb.org"
/> />
</StyledLinkToFdNetwork> </StyledLinkToFdNetwork>
</> </>
@ -244,18 +246,17 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
private maybeRenderLocalGraph = () => { private maybeRenderLocalGraph = () => {
const { localGraph } = this.state; const { localGraph } = this.state;
const hasLocalGraph = const hasLocalGraph = !!localGraph && localGraph.nodes.length > 0 && localGraph.edges.length > 0;
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0; if (!hasLocalGraph || !localGraph) {
if (!hasLocalGraph) {
return; return;
} }
return ( return (
<StyledGraphContainer> <StyledGraphContainer aria-hidden>
<Cytoscape <Cytoscape
elements={localGraph!} elements={localGraph}
currentNodeId={this.props.instanceName} currentNodeId={this.props.instanceName}
navigateToInstancePath={this.props.navigateToInstance} navigateToInstancePath={this.props.navigateToInstance}
showEdges={true} showEdges
/> />
<Divider /> <Divider />
</StyledGraphContainer> </StyledGraphContainer>
@ -268,7 +269,11 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
}; };
private renderDescription = () => { private renderDescription = () => {
const description = this.props.instanceDetails!.description; const { instanceDetails } = this.props;
if (!instanceDetails) {
return;
}
const { description } = instanceDetails;
if (!description) { if (!description) {
return; return;
} }
@ -288,10 +293,10 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
insularity, insularity,
type, type,
statusesPerDay, statusesPerDay,
statusesPerUserPerDay statusesPerUserPerDay,
} = this.props.instanceDetails; } = this.props.instanceDetails;
return ( return (
<StyledHTMLTable small={true} striped={true}> <StyledHTMLTable small striped>
<tbody> <tbody>
<tr> <tr>
<td>Version</td> <td>Version</td>
@ -299,7 +304,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td>Instance type</td> <td>Instance type</td>
<td>{(type && <InstanceType type={type} colorAfterName={true} />) || "Unknown"}</td> <td>{(type && <InstanceType type={type} colorAfterName />) || "Unknown"}</td>
</tr> </tr>
<tr> <tr>
<td>Users</td> <td>Users</td>
@ -311,7 +316,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td> <td>
Insularity{" "} Insularity
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -330,7 +336,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td> <td>
Statuses / day{" "} Statuses / day
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -349,7 +356,8 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td> <td>
Statuses / person / day{" "} Statuses / person / day
{" "}
<Tooltip <Tooltip
content={ content={
<span> <span>
@ -372,7 +380,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</tr> </tr>
<tr> <tr>
<td>Last updated</td> <td>Last updated</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td> <td>{moment(`${lastUpdated}Z`).fromNow() || "Unknown"}</td>
</tr> </tr>
</tbody> </tbody>
</StyledHTMLTable> </StyledHTMLTable>
@ -412,7 +420,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
would mean that every single status on {this.props.instanceName} contained a mention of someone on the other would mean that every single status on {this.props.instanceName} contained a mention of someone on the other
instance, and vice versa. instance, and vice versa.
</p> </p>
<StyledHTMLTable small={true} striped={true} interactive={false}> <StyledHTMLTable small striped interactive={false}>
<thead> <thead>
<tr> <tr>
<th>Instance</th> <th>Instance</th>
@ -426,11 +434,15 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
}; };
private renderPeers = () => { private renderPeers = () => {
const peers = this.props.instanceDetails!.peers; const { instanceDetails } = this.props;
if (!instanceDetails) {
return;
}
const { peers } = instanceDetails;
if (!peers || peers.length === 0) { if (!peers || peers.length === 0) {
return; return;
} }
const peerRows = peers.map(instance => ( const peerRows = peers.map((instance: Peer) => (
<tr key={instance.name}> <tr key={instance.name}>
<td> <td>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button"> <Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
@ -444,7 +456,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
<p className={Classes.TEXT_MUTED}> <p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about. All the instances, past and present, that {this.props.instanceName} knows about.
</p> </p>
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table"> <StyledHTMLTable small striped interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody> <tbody>{peerRows}</tbody>
</StyledHTMLTable> </StyledHTMLTable>
</div> </div>
@ -453,71 +465,62 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />; private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => { private renderPersonalInstanceErrorState = () => (
return ( <NonIdealState
<NonIdealState icon={IconNames.BLOCKED_PERSON}
icon={IconNames.BLOCKED_PERSON} title="No data"
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."
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={
action={ <Link to="/admin" className={Classes.BUTTON} role="button">
<Link to={"/admin"} className={Classes.BUTTON} role="button"> Opt in
{"Opt in"} </Link>
</Link> }
} />
/> );
);
};
private renderMissingDataState = () => { private renderMissingDataState = () => (
return ( <>
<> <NonIdealState
<NonIdealState icon={IconNames.ERROR}
icon={IconNames.ERROR} title="No data"
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."
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" }}>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}> {this.props.instanceDetails && this.props.instanceDetails.status}
{this.props.instanceDetails && this.props.instanceDetails.status} </span>
</>
);
private renderRobotsTxtState = () => (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
</span> </span>
</> }
); title="No data"
}; description="This instance was not crawled because its robots.txt did not allow us to."
/>
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 = () => { private openInstanceLink = () => {
window.open("https://" + this.props.instanceName, "_blank"); window.open(`https://${this.props.instanceName}`, "_blank");
}; };
} }
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => {
const match = domainMatchSelector(state); const match = domainMatchSelector(state);
return { return {
graph: state.data.graphResponse && state.data.graphResponse.graph, graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails, instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error, instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain, instanceName: match && match.params.domain,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails,
}; };
}; };
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
navigateToRoot: () => dispatch(push("/")) navigateToRoot: () => dispatch(push("/")),
}); });
const InstanceScreen = connect( const InstanceScreen = connect(mapStateToProps, mapDispatchToProps)(InstanceScreenImpl);
mapStateToProps,
mapDispatchToProps
)(InstanceScreenImpl);
export default InstanceScreen; export default InstanceScreen;

View File

@ -1,4 +1,4 @@
import { Button, Classes, FormGroup, H1, H4, Icon, InputGroup, Intent } from "@blueprintjs/core"; import { Button, Classes, FormGroup, H1, H2, Icon, InputGroup, Intent } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import React from "react"; import React from "react";
import { Redirect } from "react-router"; import { Redirect } from "react-router";
@ -8,11 +8,11 @@ import { getAuthToken, getFromApi, postToApi } from "../../util";
import { Page } from "../atoms"; import { Page } from "../atoms";
import { ErrorState } from "../molecules"; import { ErrorState } from "../molecules";
interface IFormContainerProps { interface FormContainerProps {
error: boolean; error: boolean;
} }
const FormContainer = styled.div<IFormContainerProps>` const FormContainer = styled.div<FormContainerProps>`
${props => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")} ${(props) => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
`; `;
const LoginTypeContainer = styled.div` const LoginTypeContainer = styled.div`
display: flex; display: flex;
@ -31,23 +31,28 @@ const StyledIcon = styled(Icon)`
margin-bottom: 10px; margin-bottom: 10px;
`; `;
interface ILoginTypes { interface LoginTypes {
domain: string; domain: string;
email?: string; email?: string;
fediverseAccount?: string; fediverseAccount?: string;
} }
interface ILoginScreenState { interface LoginScreenState {
domain: string; domain: string;
isGettingLoginTypes: boolean; isGettingLoginTypes: boolean;
isSendingLoginRequest: boolean; isSendingLoginRequest: boolean;
loginTypes?: ILoginTypes; loginTypes?: LoginTypes;
selectedLoginType?: "email" | "fediverseAccount"; selectedLoginType?: "email" | "fediverseAccount";
error: boolean; error: boolean;
} }
class LoginScreen extends React.PureComponent<{}, ILoginScreenState> { class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
public constructor(props: any) { public constructor(props: any) {
super(props); super(props);
this.state = { domain: "", error: false, isGettingLoginTypes: false, isSendingLoginRequest: false }; this.state = {
domain: "",
error: false,
isGettingLoginTypes: false,
isSendingLoginRequest: false,
};
} }
public render() { public render() {
@ -59,13 +64,13 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state; const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state;
let content; let content;
if (!!error) { if (error) {
content = ( content = (
<ErrorState description="This could be because the instance is down. If not, please reload the page and try again." /> <ErrorState description="This could be because the instance is down. If not, please reload the page and try again." />
); );
} else if (!!selectedLoginType && !isSendingLoginRequest) { } else if (!!selectedLoginType && !isSendingLoginRequest) {
content = this.renderPostLogin(); content = this.renderPostLogin();
} else if (!!loginTypes) { } else if (loginTypes) {
content = this.renderChooseLoginType(); content = this.renderChooseLoginType();
} else { } else {
content = this.renderChooseInstance(); content = this.renderChooseInstance();
@ -74,16 +79,14 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
return ( return (
<Page> <Page>
<H1>Login</H1> <H1>Login</H1>
<p className={Classes.RUNNING_TEXT}>You must be the instance admin to manage how fediverse.space</p>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
You must be the instance admin to manage how fediverse.space interacts with your instance. It&apos;s currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
</p> direct message, your instance must federate with social.inex.rocks and vice versa.
<p className={Classes.RUNNING_TEXT}>
It's currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
direct message, your instance must federate with mastodon.social and vice versa.
</p> </p>
<p className={Classes.RUNNING_TEXT}> <p className={Classes.RUNNING_TEXT}>
If you run another server type, you can manually opt in or out by writing to{" "} If you run another server type, you can manually opt in or out by writing to{" "}
<a href="https://mastodon.social/@fediversespace">@fediversespace</a>. <a href="https://social.inex.rocks/@indexCommunity">@indexCommunity</a>.
</p> </p>
<FormContainer error={this.state.error}>{content}</FormContainer> <FormContainer error={this.state.error}>{content}</FormContainer>
</Page> </Page>
@ -95,7 +98,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
const onButtonClick = () => this.getLoginTypes(); const onButtonClick = () => this.getLoginTypes();
return ( return (
<form onSubmit={this.getLoginTypes}> <form onSubmit={this.getLoginTypes}>
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline={true}> <FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline>
<InputGroup <InputGroup
disabled={isGettingLoginTypes} disabled={isGettingLoginTypes}
id="domain-input" id="domain-input"
@ -104,7 +107,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
rightElement={ rightElement={
<Button <Button
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
minimal={true} minimal
rightIcon={IconNames.ARROW_RIGHT} rightIcon={IconNames.ARROW_RIGHT}
title="submit" title="submit"
loading={isGettingLoginTypes} loading={isGettingLoginTypes}
@ -127,21 +130,16 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
const loginWithDm = () => this.login("fediverseAccount"); const loginWithDm = () => this.login("fediverseAccount");
return ( return (
<> <>
<H4>Choose an authentication method</H4> <H2>Choose an authentication method</H2>
<LoginTypeContainer> <LoginTypeContainer>
{loginTypes.email && ( {loginTypes.email && (
<LoginTypeButton <LoginTypeButton large icon={IconNames.ENVELOPE} onClick={loginWithEmail} loading={!!isSendingLoginRequest}>
large={true}
icon={IconNames.ENVELOPE}
onClick={loginWithEmail}
loading={!!isSendingLoginRequest}
>
{`Email ${loginTypes.email}`} {`Email ${loginTypes.email}`}
</LoginTypeButton> </LoginTypeButton>
)} )}
{loginTypes.fediverseAccount && ( {loginTypes.fediverseAccount && (
<LoginTypeButton <LoginTypeButton
large={true} large
icon={IconNames.GLOBE_NETWORK} icon={IconNames.GLOBE_NETWORK}
onClick={loginWithDm} onClick={loginWithDm}
loading={!!isSendingLoginRequest} loading={!!isSendingLoginRequest}
@ -175,7 +173,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
}; };
private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => { private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => {
if (!!e) { if (e) {
e.preventDefault(); e.preventDefault();
} }
this.setState({ isGettingLoginTypes: true }); this.setState({ isGettingLoginTypes: true });
@ -184,8 +182,8 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
domain = domain.slice(8); domain = domain.slice(8);
} }
getFromApi(`admin/login/${domain.trim()}`) getFromApi(`admin/login/${domain.trim()}`)
.then(response => { .then((response) => {
if (!!response.error) { if (response.error) {
// Go to catch() below // Go to catch() below
throw new Error(response.error); throw new Error(response.error);
} else { } else {
@ -196,7 +194,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
AppToaster.show({ AppToaster.show({
icon: IconNames.ERROR, icon: IconNames.ERROR,
intent: Intent.DANGER, intent: Intent.DANGER,
message: err.message message: err.message,
}); });
this.setState({ isGettingLoginTypes: false }); this.setState({ isGettingLoginTypes: false });
}); });
@ -205,7 +203,7 @@ class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
private login = (type: "email" | "fediverseAccount") => { private login = (type: "email" | "fediverseAccount") => {
this.setState({ isSendingLoginRequest: true, selectedLoginType: type }); this.setState({ isSendingLoginRequest: true, selectedLoginType: type });
postToApi("admin/login", { domain: this.state.loginTypes!.domain, type }) postToApi("admin/login", { domain: this.state.loginTypes!.domain, type })
.then(response => { .then((response) => {
if ("error" in response || "errors" in response) { if ("error" in response || "errors" in response) {
// Go to catch() below // Go to catch() below
throw new Error(); throw new Error();

View File

@ -1,4 +1,4 @@
import { Button, Callout, H2, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core"; import { Button, Callout, H1, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { get, isEqual } from "lodash"; import { get, isEqual } from "lodash";
@ -7,20 +7,20 @@ import { connect } from "react-redux";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import styled from "styled-components"; import styled from "styled-components";
import { setResultHover, updateSearch } from "../../redux/actions"; import { setResultHover, updateSearch } from "../../redux/actions";
import { IAppState, ISearchResultInstance } from "../../redux/types"; import { AppState, SearchResultInstance } from "../../redux/types";
import { ISearchFilter } from "../../searchFilters"; import { SearchFilter } from "../../searchFilters";
import { isSmallScreen } from "../../util"; import { isSmallScreen } from "../../util";
import { SearchResult } from "../molecules"; import { SearchResult } from "../molecules";
import { SearchFilters } from "../organisms"; import { SearchFilters } from "../organisms";
interface ISearchBarContainerProps { interface SearchBarContainerProps {
hasSearchResults: boolean; hasSearchResults: boolean;
hasError: boolean; hasError: boolean;
} }
const SearchBarContainer = styled.div<ISearchBarContainerProps>` const SearchBarContainer = styled.div<SearchBarContainerProps>`
width: 80%; width: 80%;
text-align: center; text-align: center;
margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")}; margin: ${(props) => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
align-self: center; align-self: center;
`; `;
const SearchResults = styled.div` const SearchResults = styled.div`
@ -38,22 +38,22 @@ const CalloutContainer = styled.div`
text-align: left; text-align: left;
`; `;
interface ISearchScreenProps { interface SearchScreenProps {
error: boolean; error: boolean;
isLoadingResults: boolean; isLoadingResults: boolean;
query: string; query: string;
hasMoreResults: boolean; hasMoreResults: boolean;
results: ISearchResultInstance[]; results: SearchResultInstance[];
handleSearch: (query: string, filters: ISearchFilter[]) => void; handleSearch: (query: string, filters: SearchFilter[]) => void;
navigateToInstance: (domain: string) => void; navigateToInstance: (domain: string) => void;
setIsHoveringOver: (domain?: string) => void; setIsHoveringOver: (domain?: string) => void;
} }
interface ISearchScreenState { interface SearchScreenState {
currentQuery: string; currentQuery: string;
searchFilters: ISearchFilter[]; searchFilters: SearchFilter[];
} }
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> { class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenState> {
public constructor(props: ISearchScreenProps) { public constructor(props: SearchScreenProps) {
super(props); super(props);
this.state = { currentQuery: "", searchFilters: [] }; this.state = { currentQuery: "", searchFilters: [] };
} }
@ -81,7 +81,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
} else if (!!results && results.length > 0) { } else if (!!results && results.length > 0) {
content = ( content = (
<SearchResults> <SearchResults>
{results.map(result => ( {results.map((result) => (
<SearchResult <SearchResult
result={result} result={result}
key={result.name} key={result.name}
@ -92,7 +92,7 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
))} ))}
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />} {isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
{!isLoadingResults && hasMoreResults && ( {!isLoadingResults && hasMoreResults && (
<Button onClick={this.search} minimal={true}> <Button onClick={this.search} minimal>
Load more results Load more results
</Button> </Button>
)} )}
@ -104,11 +104,11 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
if (isLoadingResults) { if (isLoadingResults) {
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />; rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
} else if (query || error) { } else if (query || error) {
rightSearchBarElement = <Button minimal={true} icon={IconNames.CROSS} onClick={this.clearQuery} />; rightSearchBarElement = <Button minimal icon={IconNames.CROSS} onClick={this.clearQuery} aria-label="Search" />;
} else { } else {
rightSearchBarElement = ( rightSearchBarElement = (
<Button <Button
minimal={true} minimal
icon={IconNames.ARROW_RIGHT} icon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
onClick={this.search} onClick={this.search}
@ -121,12 +121,13 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
<> <>
{isSmallScreen && results.length === 0 && this.renderMobileWarning()} {isSmallScreen && results.length === 0 && this.renderMobileWarning()}
<SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}> <SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}>
<H2>Find an instance</H2> <H1>Find an instance</H1>
<InputGroup <InputGroup
leftIcon={IconNames.SEARCH} leftIcon={IconNames.SEARCH}
rightElement={rightSearchBarElement} rightElement={rightSearchBarElement}
large={true} large
placeholder="Search instance names and descriptions" placeholder="Search instance names and descriptions"
aria-label="Search instance names and descriptions"
type="search" type="search"
value={this.state.currentQuery} value={this.state.currentQuery}
onChange={this.handleInputChange} onChange={this.handleInputChange}
@ -161,10 +162,10 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", [])); this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
}; };
private selectSearchFilter = (filter: ISearchFilter) => { private selectSearchFilter = (filter: SearchFilter) => {
const { searchFilters } = this.state; const { searchFilters } = this.state;
// Don't add the same filters twice // Don't add the same filters twice
if (searchFilters.some(sf => isEqual(sf, filter))) { if (searchFilters.some((sf) => isEqual(sf, filter))) {
return; return;
} }
this.setState({ searchFilters: [...searchFilters, filter] }, this.search); this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
@ -173,9 +174,9 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => { private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
const { searchFilters } = this.state; const { searchFilters } = this.state;
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", ""); const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
if (!!displayValueToRemove) { if (displayValueToRemove) {
this.setState( this.setState(
{ searchFilters: searchFilters.filter(sf => sf.displayValue !== displayValueToRemove) }, { searchFilters: searchFilters.filter((sf) => sf.displayValue !== displayValueToRemove) },
this.search this.search
); );
} }
@ -204,19 +205,16 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
); );
} }
const mapStateToProps = (state: IAppState) => ({ const mapStateToProps = (state: AppState) => ({
error: state.search.error, error: state.search.error,
hasMoreResults: !!state.search.next, hasMoreResults: !!state.search.next,
isLoadingResults: state.search.isLoadingResults, isLoadingResults: state.search.isLoadingResults,
query: state.search.query, query: state.search.query,
results: state.search.results results: state.search.results,
}); });
const mapDispatchToProps = (dispatch: Dispatch) => ({ const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string, filters: ISearchFilter[]) => dispatch(updateSearch(query, filters) as any), handleSearch: (query: string, filters: SearchFilter[]) => dispatch(updateSearch(query, filters) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)), navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)) setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)),
}); });
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(SearchScreen);
mapStateToProps,
mapDispatchToProps
)(SearchScreen);

View File

@ -6,8 +6,8 @@ import { InstanceTable } from "../organisms";
class TableScreen extends React.PureComponent { class TableScreen extends React.PureComponent {
public render() { public render() {
return ( return (
<Page fullWidth={true}> <Page fullWidth>
<H1>{"Instances"}</H1> <H1>Instances</H1>
<InstanceTable /> <InstanceTable />
</Page> </Page>
); );

View File

@ -1,20 +1,20 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Redirect } from "react-router"; import { Redirect } from "react-router";
import { IAppState } from "../../redux/types"; import { AppState } from "../../redux/types";
import { setAuthToken } from "../../util"; import { setAuthToken } from "../../util";
import { Page } from "../atoms"; import { Page } from "../atoms";
interface IVerifyLoginScreenProps { interface VerifyLoginScreenProps {
search: string; search: string;
} }
const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => { const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => {
const [didSaveToken, setDidSaveToken] = useState(false); const [didSaveToken, setDidSaveToken] = useState(false);
const token = new URLSearchParams(search).get("token"); const token = new URLSearchParams(search).get("token");
useEffect(() => { useEffect(() => {
// Save the auth token // Save the auth token
if (!!token) { if (token) {
setAuthToken(token); setAuthToken(token);
setDidSaveToken(true); setDidSaveToken(true);
} }
@ -22,15 +22,14 @@ const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => {
if (!token) { if (!token) {
return <Redirect to="/admin/login" />; return <Redirect to="/admin/login" />;
} else if (!didSaveToken) { }
if (!didSaveToken) {
return <Page />; return <Page />;
} }
return <Redirect to="/admin" />; return <Redirect to="/admin" />;
}; };
const mapStateToProps = (state: IAppState) => { const mapStateToProps = (state: AppState) => ({
return { search: state.router.location.search,
search: state.router.location.search });
};
};
export default connect(mapStateToProps)(VerifyLoginScreen); export default connect(mapStateToProps)(VerifyLoginScreen);

View File

@ -20,8 +20,9 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#AD99FF", "#AD99FF",
"#0E5A8A", "#0E5A8A",
"#0A6640", "#0A6640",
"#AAB42F",
"#A66321", "#A66321",
"#A82A2A" "#A82A2A",
]; ];
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes // From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
@ -35,11 +36,11 @@ export const QUANTITATIVE_COLOR_SCHEME = [
"#C15B3F", "#C15B3F",
"#B64C2F", "#B64C2F",
"#AA3C1F", "#AA3C1F",
"#9E2B0E" "#9E2B0E",
]; ];
export const INSTANCE_DOMAIN_PATH = "/instance/:domain"; export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath { export interface InstanceDomainPath {
domain: string; domain: string;
} }
@ -55,5 +56,6 @@ export const INSTANCE_TYPES = [
"friendica", "friendica",
"hubzilla", "hubzilla",
"plume", "plume",
"wordpress" "wordpress",
"smithereen",
]; ];

View File

@ -12,3 +12,7 @@ body {
.app-toaster { .app-toaster {
z-index: 1000; z-index: 1000;
} }
.current-navbar-item {
background-color: #293742 !important;
}

View File

@ -25,8 +25,7 @@ FocusStyleManager.onlyShowFocusOnTabs();
export const history = createBrowserHistory(); export const history = createBrowserHistory();
// Initialize redux // Initialize redux
// @ts-ignore const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore( const store = createStore(
createRootReducer(history), createRootReducer(history),
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk)) composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))

View File

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

View File

@ -2,171 +2,145 @@ import { isEqual } from "lodash";
import { Dispatch } from "redux"; import { Dispatch } from "redux";
import { push } from "connected-react-router"; import { push } from "connected-react-router";
import { ISearchFilter } from "../searchFilters"; import { SearchFilter } from "../searchFilters";
import { getFromApi } from "../util"; import { getFromApi } from "../util";
import { ActionType, IAppState, IGraph, IInstanceDetails, IInstanceSort, ISearchResponse } from "./types"; import { ActionType, AppState, Graph, InstanceDetails, InstanceSort, SearchResponse } from "./types";
// Instance details // Instance details
const requestInstanceDetails = (instanceName: string) => { const requestInstanceDetails = (instanceName: string) => ({
return { payload: instanceName,
payload: instanceName, type: ActionType.REQUEST_INSTANCE_DETAILS,
type: ActionType.REQUEST_INSTANCE_DETAILS });
}; const receiveInstanceDetails = (instanceDetails: InstanceDetails) => ({
}; payload: instanceDetails,
const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => { type: ActionType.RECEIVE_INSTANCE_DETAILS,
return { });
payload: instanceDetails, const instanceLoadFailed = () => ({
type: ActionType.RECEIVE_INSTANCE_DETAILS type: ActionType.INSTANCE_LOAD_ERROR,
}; });
}; const deselectInstance = () => ({
const instanceLoadFailed = () => { type: ActionType.DESELECT_INSTANCE,
return { });
type: ActionType.INSTANCE_LOAD_ERROR
};
};
const deselectInstance = () => {
return {
type: ActionType.DESELECT_INSTANCE
};
};
// Graph // Graph
const requestGraph = () => { const requestGraph = () => ({
return { type: ActionType.REQUEST_GRAPH,
type: ActionType.REQUEST_GRAPH });
}; const receiveGraph = (graph: Graph) => ({
}; payload: graph,
const receiveGraph = (graph: IGraph) => { type: ActionType.RECEIVE_GRAPH,
return { });
payload: graph, const graphLoadFailed = () => ({
type: ActionType.RECEIVE_GRAPH type: ActionType.GRAPH_LOAD_ERROR,
}; });
};
const graphLoadFailed = () => {
return {
type: ActionType.GRAPH_LOAD_ERROR
};
};
// Instance list // Instance list
const requestInstanceList = (sort?: IInstanceSort) => ({ const requestInstanceList = (sort?: InstanceSort) => ({
payload: sort, payload: sort,
type: ActionType.REQUEST_INSTANCES type: ActionType.REQUEST_INSTANCES,
}); });
const receiveInstanceList = (instances: IInstanceDetails[]) => ({ const receiveInstanceList = (instances: InstanceDetails[]) => ({
payload: instances, payload: instances,
type: ActionType.RECEIVE_INSTANCES type: ActionType.RECEIVE_INSTANCES,
}); });
const instanceListLoadFailed = () => ({ const instanceListLoadFailed = () => ({
type: ActionType.INSTANCE_LIST_LOAD_ERROR type: ActionType.INSTANCE_LIST_LOAD_ERROR,
}); });
// Search // Search
const requestSearchResult = (query: string, filters: ISearchFilter[]) => { const requestSearchResult = (query: string, filters: SearchFilter[]) => ({
return { payload: { query, filters },
payload: { query, filters }, type: ActionType.REQUEST_SEARCH_RESULTS,
type: ActionType.REQUEST_SEARCH_RESULTS });
}; const receiveSearchResults = (result: SearchResponse) => ({
}; payload: result,
const receiveSearchResults = (result: ISearchResponse) => { type: ActionType.RECEIVE_SEARCH_RESULTS,
return { });
payload: result, const searchFailed = () => ({
type: ActionType.RECEIVE_SEARCH_RESULTS type: ActionType.SEARCH_RESULTS_ERROR,
}; });
};
const searchFailed = () => {
return {
type: ActionType.SEARCH_RESULTS_ERROR
};
};
const resetSearch = () => { const resetSearch = () => ({
return { type: ActionType.RESET_SEARCH,
type: ActionType.RESET_SEARCH });
};
};
export const setResultHover = (domain?: string) => { export const setResultHover = (domain?: string) => ({
return { payload: domain,
payload: domain, type: ActionType.SET_SEARCH_RESULT_HOVER,
type: ActionType.SET_SEARCH_RESULT_HOVER });
};
};
/** Async actions: https://redux.js.org/advanced/asyncactions */ /** Async actions: https://redux.js.org/advanced/asyncactions */
export const loadInstance = (instanceName: string | null) => { export const loadInstance = (instanceName: string | null) => (dispatch: Dispatch, getState: () => AppState) => {
return (dispatch: Dispatch, getState: () => IAppState) => { if (!instanceName) {
if (!instanceName) { dispatch(deselectInstance());
dispatch(deselectInstance()); if (getState().router.location.pathname.startsWith("/instance/")) {
if (getState().router.location.pathname.startsWith("/instance/")) { dispatch(push("/"));
dispatch(push("/"));
}
return;
} }
dispatch(requestInstanceDetails(instanceName)); return;
return getFromApi("instances/" + instanceName) }
.then(details => dispatch(receiveInstanceDetails(details))) dispatch(requestInstanceDetails(instanceName));
.catch(() => dispatch(instanceLoadFailed())); return getFromApi(`instances/${instanceName}`)
}; .then((details) => dispatch(receiveInstanceDetails(details)))
.catch(() => dispatch(instanceLoadFailed()));
}; };
export const updateSearch = (query: string, filters: ISearchFilter[]) => { export const updateSearch = (query: string, filters: SearchFilter[]) => (
return (dispatch: Dispatch, getState: () => IAppState) => { dispatch: Dispatch,
query = query.trim(); getState: () => AppState
) => {
query = query.trim();
if (!query) { if (!query) {
dispatch(resetSearch()); dispatch(resetSearch());
return; return;
} }
const prevQuery = getState().search.query; const prevQuery = getState().search.query;
const prevFilters = getState().search.filters; const prevFilters = getState().search.filters;
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters); const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
const next = getState().search.next; const { next } = getState().search;
let url = `search/?query=${query}`; let url = `search/?query=${query}`;
if (!isNewQuery && next) { if (!isNewQuery && next) {
url += `&after=${next}`; url += `&after=${next}`;
} }
// Add filters // Add filters
// The format is e.g. type_eq=mastodon or user_count_gt=1000 // The format is e.g. type_eq=mastodon or user_count_gt=1000
filters.forEach(filter => { filters.forEach((filter) => {
url += `&${filter.field}_${filter.relation}=${filter.value}`; url += `&${filter.field}_${filter.relation}=${filter.value}`;
}); });
dispatch(requestSearchResult(query, filters)); dispatch(requestSearchResult(query, filters));
return getFromApi(url) return getFromApi(url)
.then(result => dispatch(receiveSearchResults(result))) .then((result) => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed())); .catch(() => dispatch(searchFailed()));
};
}; };
export const fetchGraph = () => { export const fetchGraph = () => (dispatch: Dispatch) => {
return (dispatch: Dispatch) => { dispatch(requestGraph());
dispatch(requestGraph()); return getFromApi("graph")
return getFromApi("graph") .then((graph) => dispatch(receiveGraph(graph)))
.then(graph => dispatch(receiveGraph(graph))) .catch(() => dispatch(graphLoadFailed()));
.catch(() => dispatch(graphLoadFailed()));
};
}; };
export const loadInstanceList = (page?: number, sort?: IInstanceSort) => { export const loadInstanceList = (page?: number, sort?: InstanceSort) => (
return (dispatch: Dispatch, getState: () => IAppState) => { dispatch: Dispatch,
sort = sort ? sort : getState().data.instanceListSort; getState: () => AppState
dispatch(requestInstanceList(sort)); ) => {
const params: string[] = []; sort = sort || getState().data.instanceListSort;
if (!!page) { dispatch(requestInstanceList(sort));
params.push(`page=${page}`); const params: string[] = [];
} if (page) {
if (!!sort) { params.push(`page=${page}`);
params.push(`sortField=${sort.field}`); }
params.push(`sortDirection=${sort.direction}`); if (sort) {
} params.push(`sortField=${sort.field}`);
const path = !!params ? `instances?${params.join("&")}` : "instances"; params.push(`sortDirection=${sort.direction}`);
return getFromApi(path) }
.then(instancesListResponse => dispatch(receiveInstanceList(instancesListResponse))) const path = params ? `instances?${params.join("&")}` : "instances";
.catch(() => dispatch(instanceListLoadFailed())); return getFromApi(path)
}; .then((instancesListResponse) => dispatch(receiveInstanceList(instancesListResponse)))
.catch(() => dispatch(instanceListLoadFailed()));
}; };

View File

@ -3,34 +3,34 @@ import { isEqual } from "lodash";
import { combineReducers } from "redux"; import { combineReducers } from "redux";
import { History } from "history"; import { History } from "history";
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types"; import { ActionType, Action, CurrentInstanceState, DataState, SearchState } from "./types";
const initialDataState: IDataState = { const initialDataState: DataState = {
graphLoadError: false, graphLoadError: false,
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: { field: "userCount", direction: "desc" }, instanceListSort: { field: "userCount", direction: "desc" },
isLoadingGraph: false, isLoadingGraph: false,
isLoadingInstanceList: false isLoadingInstanceList: false,
}; };
const data = (state: IDataState = initialDataState, action: IAction): IDataState => { const data = (state: DataState = initialDataState, action: Action): DataState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_GRAPH: case ActionType.REQUEST_GRAPH:
return { return {
...state, ...state,
graphResponse: undefined, graphResponse: undefined,
isLoadingGraph: true isLoadingGraph: true,
}; };
case ActionType.RECEIVE_GRAPH: case ActionType.RECEIVE_GRAPH:
return { return {
...state, ...state,
graphResponse: action.payload, graphResponse: action.payload,
isLoadingGraph: false isLoadingGraph: false,
}; };
case ActionType.GRAPH_LOAD_ERROR: case ActionType.GRAPH_LOAD_ERROR:
return { return {
...state, ...state,
graphLoadError: true, graphLoadError: true,
isLoadingGraph: false isLoadingGraph: false,
}; };
case ActionType.REQUEST_INSTANCES: case ActionType.REQUEST_INSTANCES:
return { return {
@ -38,71 +38,71 @@ const data = (state: IDataState = initialDataState, action: IAction): IDataState
instanceListLoadError: false, instanceListLoadError: false,
instanceListSort: action.payload, instanceListSort: action.payload,
instancesResponse: undefined, instancesResponse: undefined,
isLoadingInstanceList: true isLoadingInstanceList: true,
}; };
case ActionType.RECEIVE_INSTANCES: case ActionType.RECEIVE_INSTANCES:
return { return {
...state, ...state,
instancesResponse: action.payload, instancesResponse: action.payload,
isLoadingInstanceList: false isLoadingInstanceList: false,
}; };
case ActionType.INSTANCE_LIST_LOAD_ERROR: case ActionType.INSTANCE_LIST_LOAD_ERROR:
return { return {
...state, ...state,
instanceListLoadError: true, instanceListLoadError: true,
isLoadingInstanceList: false isLoadingInstanceList: false,
}; };
default: default:
return state; return state;
} }
}; };
const initialCurrentInstanceState: ICurrentInstanceState = { const initialCurrentInstanceState: CurrentInstanceState = {
currentInstanceDetails: null, currentInstanceDetails: null,
error: false, error: false,
isLoadingInstanceDetails: false isLoadingInstanceDetails: false,
}; };
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => { const currentInstance = (state = initialCurrentInstanceState, action: Action): CurrentInstanceState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_INSTANCE_DETAILS: case ActionType.REQUEST_INSTANCE_DETAILS:
return { return {
...state, ...state,
error: false, error: false,
isLoadingInstanceDetails: true isLoadingInstanceDetails: true,
}; };
case ActionType.RECEIVE_INSTANCE_DETAILS: case ActionType.RECEIVE_INSTANCE_DETAILS:
return { return {
...state, ...state,
currentInstanceDetails: action.payload, currentInstanceDetails: action.payload,
error: false, error: false,
isLoadingInstanceDetails: false isLoadingInstanceDetails: false,
}; };
case ActionType.DESELECT_INSTANCE: case ActionType.DESELECT_INSTANCE:
return { return {
...state, ...state,
currentInstanceDetails: null, currentInstanceDetails: null,
error: false error: false,
}; };
case ActionType.INSTANCE_LOAD_ERROR: case ActionType.INSTANCE_LOAD_ERROR:
return { return {
...state, ...state,
error: true, error: true,
isLoadingInstanceDetails: false isLoadingInstanceDetails: false,
}; };
default: default:
return state; return state;
} }
}; };
const initialSearchState: ISearchState = { const initialSearchState: SearchState = {
error: false, error: false,
filters: [], filters: [],
isLoadingResults: false, isLoadingResults: false,
next: "", next: "",
query: "", query: "",
results: [] results: [],
}; };
const search = (state = initialSearchState, action: IAction): ISearchState => { const search = (state = initialSearchState, action: Action): SearchState => {
switch (action.type) { switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS: case ActionType.REQUEST_SEARCH_RESULTS:
const { query, filters } = action.payload; const { query, filters } = action.payload;
@ -114,7 +114,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
isLoadingResults: true, isLoadingResults: true,
next: isNewQuery ? "" : state.next, next: isNewQuery ? "" : state.next,
query, query,
results: isNewQuery ? [] : state.results results: isNewQuery ? [] : state.results,
}; };
case ActionType.RECEIVE_SEARCH_RESULTS: case ActionType.RECEIVE_SEARCH_RESULTS:
return { return {
@ -122,7 +122,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
error: false, error: false,
isLoadingResults: false, isLoadingResults: false,
next: action.payload.next, next: action.payload.next,
results: state.results.concat(action.payload.results) results: state.results.concat(action.payload.results),
}; };
case ActionType.SEARCH_RESULTS_ERROR: case ActionType.SEARCH_RESULTS_ERROR:
return { ...initialSearchState, error: true }; return { ...initialSearchState, error: true };
@ -131,7 +131,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
case ActionType.SET_SEARCH_RESULT_HOVER: case ActionType.SET_SEARCH_RESULT_HOVER:
return { return {
...state, ...state,
hoveringOverResult: action.payload hoveringOverResult: action.payload,
}; };
default: default:
return state; return state;
@ -141,8 +141,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
export default (history: History) => export default (history: History) =>
combineReducers({ combineReducers({
router: connectRouter(history), router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys
currentInstance, currentInstance,
data, data,
search search,
}); });

View File

@ -1,5 +1,5 @@
import { RouterState } from "connected-react-router"; import { RouterState } from "connected-react-router";
import { ISearchFilter } from "../searchFilters"; import { SearchFilter } from "../searchFilters";
export enum ActionType { export enum ActionType {
// Instance details // Instance details
@ -22,33 +22,33 @@ export enum ActionType {
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR", SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
RESET_SEARCH = "RESET_SEARCH", RESET_SEARCH = "RESET_SEARCH",
// Search -- hovering over results // Search -- hovering over results
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER" SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER",
} }
export interface IAction { export interface Action {
type: ActionType; type: ActionType;
payload: any; payload: any;
} }
export type SortField = "domain" | "userCount" | "statusCount" | "insularity"; export type SortField = "domain" | "userCount" | "statusCount" | "insularity";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export interface IInstanceSort { export interface InstanceSort {
field: SortField; field: SortField;
direction: SortDirection; direction: SortDirection;
} }
export interface IPeer { export interface Peer {
name: string; name: string;
} }
export interface ISearchResultInstance { export interface SearchResultInstance {
name: string; name: string;
description?: string; description?: string;
userCount?: number; userCount?: number;
type?: string; type?: string;
} }
export interface IFederationRestrictions { export interface FederationRestrictions {
reportRemoval?: string[]; reportRemoval?: string[];
reject?: string[]; reject?: string[];
mediaRemoval?: string[]; mediaRemoval?: string[];
@ -59,7 +59,7 @@ export interface IFederationRestrictions {
accept?: string[]; accept?: string[];
} }
export interface IInstanceDetails { export interface InstanceDetails {
name: string; name: string;
description?: string; description?: string;
version?: string; version?: string;
@ -67,8 +67,8 @@ export interface IInstanceDetails {
insularity?: number; insularity?: number;
statusCount?: number; statusCount?: number;
domainCount?: number; domainCount?: number;
peers?: IPeer[]; peers?: Peer[];
federationRestrictions: IFederationRestrictions; federationRestrictions: FederationRestrictions;
lastUpdated?: string; lastUpdated?: string;
status: string; status: string;
type?: string; type?: string;
@ -76,7 +76,7 @@ export interface IInstanceDetails {
statusesPerUserPerDay?: number; statusesPerUserPerDay?: number;
} }
interface IGraphNode { interface GraphNode {
data: { data: {
id: string; id: string;
label: string; label: string;
@ -88,7 +88,7 @@ interface IGraphNode {
}; };
} }
interface IGraphEdge { interface GraphEdge {
data: { data: {
source: string; source: string;
target: string; target: string;
@ -97,65 +97,65 @@ interface IGraphEdge {
}; };
} }
interface IGraphMetadata { interface GraphMetadata {
ranges: { [key: string]: [number, number] }; ranges: { [key: string]: [number, number] };
} }
export interface IGraph { export interface Graph {
nodes: IGraphNode[]; nodes: GraphNode[];
edges: IGraphEdge[]; edges: GraphEdge[];
} }
export interface IGraphResponse { export interface GraphResponse {
graph: IGraph; graph: Graph;
metadata: IGraphMetadata; metadata: GraphMetadata;
} }
export interface ISearchResponse { export interface SearchResponse {
results: ISearchResultInstance[]; results: SearchResultInstance[];
next: string | null; next: string | null;
} }
export interface IInstanceListResponse { export interface InstanceListResponse {
pageNumber: number; pageNumber: number;
totalPages: number; totalPages: number;
totalEntries: number; totalEntries: number;
pageSize: number; pageSize: number;
instances: IInstanceDetails[]; instances: InstanceDetails[];
} }
// Redux state // Redux state
// The current instance name is stored in the URL. See state -> router -> location // The current instance name is stored in the URL. See state -> router -> location
export interface ICurrentInstanceState { export interface CurrentInstanceState {
currentInstanceDetails: IInstanceDetails | null; currentInstanceDetails: InstanceDetails | null;
isLoadingInstanceDetails: boolean; isLoadingInstanceDetails: boolean;
error: boolean; error: boolean;
} }
export interface IDataState { export interface DataState {
graphResponse?: IGraphResponse; graphResponse?: GraphResponse;
instancesResponse?: IInstanceListResponse; instancesResponse?: InstanceListResponse;
instanceListSort: IInstanceSort; instanceListSort: InstanceSort;
isLoadingGraph: boolean; isLoadingGraph: boolean;
isLoadingInstanceList: boolean; isLoadingInstanceList: boolean;
graphLoadError: boolean; graphLoadError: boolean;
instanceListLoadError: boolean; instanceListLoadError: boolean;
} }
export interface ISearchState { export interface SearchState {
error: boolean; error: boolean;
isLoadingResults: boolean; isLoadingResults: boolean;
next: string; next: string;
query: string; query: string;
results: ISearchResultInstance[]; results: SearchResultInstance[];
filters: ISearchFilter[]; filters: SearchFilter[];
hoveringOverResult?: string; hoveringOverResult?: string;
} }
export interface IAppState { export interface AppState {
router: RouterState; router: RouterState;
currentInstance: ICurrentInstanceState; currentInstance: CurrentInstanceState;
data: IDataState; data: DataState;
search: ISearchState; search: SearchState;
} }

View File

@ -1,8 +1,8 @@
type ISearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte"; type SearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
export interface ISearchFilter { export interface SearchFilter {
// The ES field to filter on // The ES field to filter on
field: string; field: string;
relation: ISearchFilterRelation; relation: SearchFilterRelation;
// The value we want to filter to // The value we want to filter to
value: string; value: string;
// Human-meaningful text that we're showing in the UI // Human-meaningful text that we're showing in the UI
@ -10,17 +10,19 @@ export interface ISearchFilter {
} }
// Maps to translate this to user-friendly text // Maps to translate this to user-friendly text
type SearchFilterField = "type" | "user_count";
const searchFilterFieldTranslations = { const searchFilterFieldTranslations = {
type: "Instance type", type: "Instance type",
user_count: "User count" // eslint-disable-next-line @typescript-eslint/camelcase
user_count: "User count",
}; };
const searchFilterRelationTranslations = { const searchFilterRelationTranslations = {
eq: "=", eq: "=",
gt: ">", gt: ">",
gte: ">=", gte: ">=",
lt: "<", lt: "<",
lte: "<=" lte: "<=",
}; };
export const getSearchFilterDisplayValue = (field: string, relation: ISearchFilterRelation, value: string) => export const getSearchFilterDisplayValue = (field: SearchFilterField, relation: SearchFilterRelation, value: string) =>
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`; `${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;

View File

@ -2,5 +2,5 @@ import { Position, Toaster } from "@blueprintjs/core";
export default Toaster.create({ export default Toaster.create({
className: "app-toaster", className: "app-toaster",
position: Position.TOP position: Position.TOP,
}); });

View File

@ -1,6 +1,6 @@
import { INSTANCE_TYPES } from "./constants"; import { INSTANCE_TYPES } from "./constants";
interface IColorSchemeBase { interface ColorSchemeBase {
// The name of the coloring, e.g. "Instance type" // The name of the coloring, e.g. "Instance type"
name: string; name: string;
// The name of the key in a cytoscape node's `data` field to color by. // The name of the key in a cytoscape node's `data` field to color by.
@ -9,30 +9,30 @@ interface IColorSchemeBase {
description?: string; description?: string;
type: "qualitative" | "quantitative"; type: "qualitative" | "quantitative";
} }
interface IQualitativeColorScheme extends IColorSchemeBase { interface QualitativeColorScheme extends ColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"]. // The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[]; values: string[];
type: "qualitative"; type: "qualitative";
} }
interface IQuantitativeColorScheme extends IColorSchemeBase { interface QuantitativeColorScheme extends ColorSchemeBase {
type: "quantitative"; type: "quantitative";
exponential: boolean; exponential: boolean;
} }
export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme; export type ColorScheme = QualitativeColorScheme | QuantitativeColorScheme;
export const typeColorScheme: IQualitativeColorScheme = { export const typeColorScheme: QualitativeColorScheme = {
cytoscapeDataKey: "type", cytoscapeDataKey: "type",
name: "Instance type", name: "Instance type",
type: "qualitative", type: "qualitative",
values: INSTANCE_TYPES values: INSTANCE_TYPES,
}; };
export const activityColorScheme: IQuantitativeColorScheme = { export const activityColorScheme: QuantitativeColorScheme = {
cytoscapeDataKey: "statusesPerDay", cytoscapeDataKey: "statusesPerDay",
description: "The average number of statuses posted per day. This is an exponential scale.", description: "The average number of statuses posted per day. This is an exponential scale.",
exponential: true, exponential: true,
name: "Activity", name: "Activity",
type: "quantitative" type: "quantitative",
}; };
export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme]; export const colorSchemes: ColorScheme[] = [typeColorScheme, activityColorScheme];

1
frontend/src/typings/globals.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "*.png";

View File

@ -1,41 +1,39 @@
import { createMatchSelector } from "connected-react-router"; import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch"; import fetch from "cross-fetch";
import { range } from "lodash"; import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants"; import { DESKTOP_WIDTH_THRESHOLD, InstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types"; import { AppState } from "./redux/types";
let API_ROOT = "http://localhost:4000/api/"; let API_ROOT = "https://api.index.community/api/";
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) { if (["true", true, 1, "1"].includes(process.env.REACT_APP_STAGING || "")) {
API_ROOT = "https://phoenix.api.fediverse.space/api/"; API_ROOT = "https://api.index.community/api/";
} else if (process.env.NODE_ENV === "production") { } else if (process.env.NODE_ENV === "production") {
API_ROOT = "https://phoenix.api.fediverse.space/api/"; API_ROOT = "https://api.index.community/api/";
} }
export const getFromApi = (path: string, token?: string): Promise<any> => { export const getFromApi = (path: string, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/"; const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
const headers = token ? { token } : undefined; const headers = token ? { token } : undefined;
return fetch(encodeURI(domain + path), { return fetch(encodeURI(domain + path), {
headers headers,
}).then(response => response.json()); }).then((response) => response.json());
}; };
export const postToApi = (path: string, body: any, token?: string): Promise<any> => { export const postToApi = (path: string, body: any, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/"; const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
const defaultHeaders = { const defaultHeaders = {
Accept: "application/json", Accept: "application/json",
"Content-Type": "application/json" "Content-Type": "application/json",
}; };
const headers = token ? { ...defaultHeaders, token } : defaultHeaders; const headers = token ? { ...defaultHeaders, token } : defaultHeaders;
return fetch(encodeURI(domain + path), { return fetch(encodeURI(domain + path), {
body: JSON.stringify(body), body: JSON.stringify(body),
headers, headers,
method: "POST" method: "POST",
}).then(response => { }).then((response) => response.json());
return response.json();
});
}; };
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH); export const domainMatchSelector = createMatchSelector<AppState, InstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD; export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
@ -49,28 +47,25 @@ export const unsetAuthToken = () => {
sessionStorage.removeItem("adminToken"); sessionStorage.removeItem("adminToken");
}; };
export const getAuthToken = () => { export const getAuthToken = () => sessionStorage.getItem("adminToken");
return sessionStorage.getItem("adminToken");
};
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => { export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
if (exponential) { if (exponential) {
const logSpace = range(steps).map(i => Math.E ** i); const logSpace = range(steps).map((i) => Math.E ** i);
// Scale the log space to the linear range // Scale the log space to the linear range
const logRange = logSpace[logSpace.length - 1] - logSpace[0]; const logRange = logSpace[logSpace.length - 1] - logSpace[0];
const linearRange = max - min; const linearRange = max - min;
const scalingFactor = linearRange / logRange; const scalingFactor = linearRange / logRange;
const translation = min - logSpace[0]; const translation = min - logSpace[0];
return logSpace.map(i => (i + translation) * scalingFactor); return logSpace.map((i) => (i + translation) * scalingFactor);
} else {
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
} }
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
}; };
const typeToDisplay = { const typeToDisplay: { [field: string]: string } = {
gnusocial: "GNU Social" gnusocial: "GNU Social",
}; };
export const getTypeDisplayString = (key: string) => { export const getTypeDisplayString = (key: string) => {
if (key in typeToDisplay) { if (key in typeToDisplay) {

View File

@ -1,40 +1,32 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowJs": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"lib": [
"es2015",
"dom"
],
"module": "esnext",
"moduleResolution": "node",
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"outDir": "build",
"rootDir": "src",
"sourceMap": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"target": "es5", "target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true, "strict": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"jsx": "react",
"typeRoots": [ "typeRoots": [
"./node_modules/@types", "./node_modules/@types",
"./src/typings" "./src/typings"
], ],
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}, },
"exclude": [
"node_modules",
"build"
],
"include": [ "include": [
"src" "src"
] ]

View File

@ -1,11 +0,0 @@
{
"extends": [
"tslint:recommended",
"tslint-eslint-rules",
"tslint-react",
"@blueprintjs/tslint-config/blueprint-rules",
"tslint-config-prettier",
"tslint-config-security"
],
"exclude": ["**/*.css"]
}

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,9 @@
base = "frontend/" base = "frontend/"
publish = "frontend/build/" publish = "frontend/build/"
[build.environment]
INLINE_RUNTIME_CHUNK = "false"
[context.develop.environment] [context.develop.environment]
REACT_APP_STAGING = "true" REACT_APP_STAGING = "true"
@ -16,3 +19,11 @@
to = "/index.html" to = "/index.html"
status = 200 status = 200
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
X-XSS-Protection = "1"
Content-Security-Policy = "default-src 'self' https://*.fediverse.space https://plausible.cursed.technology; style-src 'self' 'unsafe-inline'; img-src 'self' data:"